From 1b1c3f350dd951eb3e131e0f093a183e6b2b49ef Mon Sep 17 00:00:00 2001 From: etiennebacher Date: Sun, 10 May 2026 19:17:57 +0100 Subject: [PATCH 01/39] init --- Cargo.lock | 735 ++++++++++++++- Cargo.toml | 4 + crates/jarl-core/Cargo.toml | 6 + crates/jarl-core/src/analyze/document.rs | 10 + crates/jarl-core/src/check.rs | 13 +- crates/jarl-core/src/lints/base/mod.rs | 1 + .../src/lints/base/unused_object/mod.rs | 826 +++++++++++++++++ .../lints/base/unused_object/unused_object.rs | 870 ++++++++++++++++++ crates/jarl-core/src/rule_set.rs | 7 + 9 files changed, 2453 insertions(+), 19 deletions(-) create mode 100644 crates/jarl-core/src/lints/base/unused_object/mod.rs create mode 100644 crates/jarl-core/src/lints/base/unused_object/unused_object.rs diff --git a/Cargo.lock b/Cargo.lock index b51175e9a..90c783d05 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -44,7 +44,7 @@ dependencies = [ "biome_formatter", "biome_rowan", "comments", - "itertools", + "itertools 0.13.0", "settings", "tracing", ] @@ -74,6 +74,15 @@ dependencies = [ "serde", ] +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "annotate-snippets" version = "0.11.5" @@ -136,9 +145,15 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.101" +version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" [[package]] name = "backtrace" @@ -155,6 +170,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "biome_console" version = "0.5.7" @@ -371,6 +392,15 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bpaf" version = "0.9.23" @@ -402,6 +432,18 @@ dependencies = [ "serde", ] +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + [[package]] name = "camino" version = "1.2.2" @@ -424,6 +466,20 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "clap" version = "4.6.0" @@ -495,6 +551,36 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[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" version = "0.8.4" @@ -551,6 +637,26 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crypto-common" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -639,12 +745,32 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "filetime" +version = "0.2.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5b2eef6fafbf69f877e55509ce5b11a760690ac9700a2921be067aa6afaef6" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "foldhash" version = "0.1.5" @@ -668,6 +794,41 @@ dependencies = [ "path-absolutize", ] +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.4.1" @@ -730,6 +891,61 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hybrid-array" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d46837a0ed51fe95bd3b05de33cd64a1ee88fc797477ca48446872504507c5" +dependencies = [ + "typenum", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.1.1" @@ -894,6 +1110,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.17" @@ -935,6 +1160,9 @@ dependencies = [ "fs", "ignore", "insta", + "oak_core", + "oak_index", + "oak_index_vec", "path-absolutize", "rayon", "regex", @@ -944,6 +1172,7 @@ dependencies = [ "tempfile", "toml", "tracing", + "url", "workspace", ] @@ -965,6 +1194,18 @@ dependencies = [ "workspace", ] +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -985,9 +1226,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.182" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "line_ending" @@ -1055,6 +1296,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", + "simd-adler32", ] [[package]] @@ -1066,6 +1308,111 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "oak_core" +version = "0.1.0" +source = "git+https://github.com/posit-dev/ark?rev=61f541f7ad201cc14a556ca54d3d933a7284f286#61f541f7ad201cc14a556ca54d3d933a7284f286" +dependencies = [ + "air_r_syntax", + "biome_rowan", +] + +[[package]] +name = "oak_fs" +version = "0.1.0" +source = "git+https://github.com/posit-dev/ark?rev=61f541f7ad201cc14a556ca54d3d933a7284f286#61f541f7ad201cc14a556ca54d3d933a7284f286" +dependencies = [ + "anyhow", + "libc", + "log", + "windows-sys 0.61.2", +] + +[[package]] +name = "oak_index" +version = "0.1.0" +source = "git+https://github.com/posit-dev/ark?rev=61f541f7ad201cc14a556ca54d3d933a7284f286#61f541f7ad201cc14a556ca54d3d933a7284f286" +dependencies = [ + "air_r_parser", + "air_r_syntax", + "anyhow", + "biome_rowan", + "biome_text_size", + "itertools 0.14.0", + "log", + "oak_core", + "oak_index_vec", + "oak_package_metadata", + "oak_sources", + "rustc-hash", + "smallvec", + "stdext", + "url", +] + +[[package]] +name = "oak_index_vec" +version = "0.1.0" +source = "git+https://github.com/posit-dev/ark?rev=61f541f7ad201cc14a556ca54d3d933a7284f286#61f541f7ad201cc14a556ca54d3d933a7284f286" +dependencies = [ + "biome_text_size", + "oak_core", +] + +[[package]] +name = "oak_package_metadata" +version = "0.1.0" +source = "git+https://github.com/posit-dev/ark?rev=61f541f7ad201cc14a556ca54d3d933a7284f286#61f541f7ad201cc14a556ca54d3d933a7284f286" +dependencies = [ + "air_r_parser", + "air_r_syntax", + "anyhow", + "biome_rowan", + "oak_core", + "regex", + "stdext", +] + +[[package]] +name = "oak_r_process" +version = "0.1.0" +source = "git+https://github.com/posit-dev/ark?rev=61f541f7ad201cc14a556ca54d3d933a7284f286#61f541f7ad201cc14a556ca54d3d933a7284f286" +dependencies = [ + "anyhow", + "log", + "tempfile", +] + +[[package]] +name = "oak_sources" +version = "0.1.0" +source = "git+https://github.com/posit-dev/ark?rev=61f541f7ad201cc14a556ca54d3d933a7284f286#61f541f7ad201cc14a556ca54d3d933a7284f286" +dependencies = [ + "anyhow", + "chrono", + "etcetera", + "flate2", + "hex", + "log", + "oak_fs", + "oak_package_metadata", + "oak_r_process", + "serde", + "serde_json", + "sha2", + "tar", + "ureq", +] + [[package]] name = "object" version = "0.37.3" @@ -1250,6 +1597,20 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustc-demangle" version = "0.1.27" @@ -1258,9 +1619,9 @@ checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" [[package]] name = "rustc-hash" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" [[package]] name = "rustix" @@ -1275,6 +1636,47 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "same-file" version = "1.0.6" @@ -1362,6 +1764,7 @@ version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ + "indexmap", "itoa", "memchr", "serde", @@ -1398,6 +1801,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sharded-slab" version = "0.1.7" @@ -1413,6 +1827,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + [[package]] name = "similar" version = "2.7.0" @@ -1423,6 +1843,12 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.15.1" @@ -1444,6 +1870,15 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "stdext" +version = "0.1.0" +source = "git+https://github.com/posit-dev/ark?rev=61f541f7ad201cc14a556ca54d3d933a7284f286#61f541f7ad201cc14a556ca54d3d933a7284f286" +dependencies = [ + "anyhow", + "log", +] + [[package]] name = "streaming-iterator" version = "0.1.9" @@ -1456,6 +1891,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "1.0.109" @@ -1489,6 +1930,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "tar" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -1496,7 +1948,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.1", "once_cell", "rustix", "windows-sys 0.61.2", @@ -1686,6 +2138,12 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + [[package]] name = "unicode-bom" version = "2.0.3" @@ -1722,6 +2180,41 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -1735,6 +2228,12 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -1763,6 +2262,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.2+wasi-0.2.9" @@ -1781,6 +2286,51 @@ dependencies = [ "wit-bindgen 0.51.0", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + [[package]] name = "wasm-encoder" version = "0.201.0" @@ -1851,6 +2401,15 @@ dependencies = [ "semver", ] +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi-util" version = "0.1.11" @@ -1860,19 +2419,81 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets", + "windows-targets 0.53.5", ] [[package]] @@ -1884,6 +2505,22 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + [[package]] name = "windows-targets" version = "0.53.5" @@ -1891,58 +2528,106 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ "windows-link", - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + [[package]] name = "windows_aarch64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + [[package]] name = "windows_aarch64_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + [[package]] name = "windows_i686_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + [[package]] name = "windows_i686_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + [[package]] name = "windows_i686_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + [[package]] name = "windows_x86_64_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + [[package]] name = "windows_x86_64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "windows_x86_64_msvc" version = "0.53.1" @@ -2164,6 +2849,16 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "xtask" version = "0.0.0" @@ -2245,6 +2940,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.3" diff --git a/Cargo.toml b/Cargo.toml index 530e3b0ed..54ab6d2be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,10 @@ air_fs = { package = "fs", git = "https://github.com/posit-dev/air", rev = "4cbd air_r_parser = { git = "https://github.com/posit-dev/air", rev = "4cbd36d552e27d8930cff1602d56bfd9ce4c1ed1" } air_r_syntax = { git = "https://github.com/posit-dev/air", rev = "4cbd36d552e27d8930cff1602d56bfd9ce4c1ed1" } air_workspace = { package = "workspace", git = "https://github.com/posit-dev/air", rev = "4cbd36d552e27d8930cff1602d56bfd9ce4c1ed1" } +oak_core = { git = "https://github.com/posit-dev/ark", rev = "61f541f7ad201cc14a556ca54d3d933a7284f286" } +oak_index = { git = "https://github.com/posit-dev/ark", rev = "61f541f7ad201cc14a556ca54d3d933a7284f286" } +oak_index_vec = { git = "https://github.com/posit-dev/ark", rev = "61f541f7ad201cc14a556ca54d3d933a7284f286" } +url = "2.5" anyhow = "1.0.94" biome_formatter = { git = "https://github.com/lionel-/biome", rev = "41d799cfa4cedd25625fc3f6bd7898532873f051" } biome_rowan = { git = "https://github.com/lionel-/biome", rev = "41d799cfa4cedd25625fc3f6bd7898532873f051" } diff --git a/crates/jarl-core/Cargo.toml b/crates/jarl-core/Cargo.toml index 07b46be04..0e3d2c2f1 100644 --- a/crates/jarl-core/Cargo.toml +++ b/crates/jarl-core/Cargo.toml @@ -23,6 +23,12 @@ air_fs.workspace = true biome_rowan.workspace = true biome_formatter.workspace = true +# Semantic analysis (per-file scopes, definitions, uses, use-def map) +oak_core.workspace = true +oak_index.workspace = true +oak_index_vec.workspace = true +url.workspace = true + # Serialization and data structures serde.workspace = true toml.workspace = true diff --git a/crates/jarl-core/src/analyze/document.rs b/crates/jarl-core/src/analyze/document.rs index df07e5763..e7b273c9a 100644 --- a/crates/jarl-core/src/analyze/document.rs +++ b/crates/jarl-core/src/analyze/document.rs @@ -1,9 +1,11 @@ use air_r_syntax::{RExpressionList, RSyntaxNode}; use biome_rowan::{AstNode, AstNodeList}; +use oak_index::semantic_index::SemanticIndex; use crate::checker::Checker; use crate::diagnostic::*; use crate::lints::base::unreachable_code::unreachable_code::unreachable_code_top_level; +use crate::lints::base::unused_object::unused_object::unused_object; use crate::lints::comments::blanket_suppression::blanket_suppression::blanket_suppression; use crate::lints::comments::invalid_chunk_suppression::invalid_chunk_suppression::invalid_chunk_suppression; use crate::lints::comments::misnamed_suppression::misnamed_suppression::misnamed_suppression; @@ -21,6 +23,7 @@ pub(crate) fn check_document( checker: &mut Checker, duplicate_assignments: &[(String, biome_rowan::TextRange, String)], unused_functions: &[(String, biome_rowan::TextRange, String)], + semantic: Option<&SemanticIndex>, ) -> anyhow::Result<()> { // --- Document-level analysis --- @@ -33,6 +36,13 @@ pub(crate) fn check_document( } } + // Check for unused local objects via the semantic index. + if checker.is_rule_enabled(Rule::UnusedObject) + && let Some(semantic) = semantic + { + unused_object(&expressions, semantic, checker)?; + } + // --- Comment/suppression checks --- // Report blanket suppression comments (file-level, done once) diff --git a/crates/jarl-core/src/check.rs b/crates/jarl-core/src/check.rs index 42185379e..ecd28d5cd 100644 --- a/crates/jarl-core/src/check.rs +++ b/crates/jarl-core/src/check.rs @@ -194,6 +194,14 @@ pub fn get_checks( checker.rule_set = config.rules_to_apply.clone(); checker.minimum_r_version = config.minimum_r_version; + // Build the semantic index for use-def-based rules. `Url::from_file_path` + // requires an absolute path; for relative paths (e.g. test inputs) fall + // back to a synthetic file URL so indexing always succeeds. + let file_url = url::Url::from_file_path(file).unwrap_or_else(|_| { + url::Url::parse(&format!("file:///{}", file.display())).expect("synthetic URL parses") + }); + let semantic = oak_index::semantic_index(&parsed.tree(), &file_url); + // Wire up package context for package-specific rules. get_package_info( &mut checker, @@ -245,6 +253,7 @@ pub fn get_checks( &mut checker, &duplicate_assignments, &unused_functions, + Some(&semantic), )?; // Some rules have a fix available in their implementation but do not have @@ -364,7 +373,7 @@ fn get_checks_roxygen( // otherwise unnecessary here (no package-level analysis, no // suppression-related diagnostics to report). if has_suppressions { - check_document(expressions, &mut checker, &[], &[])?; + check_document(expressions, &mut checker, &[], &[], None)?; } for mut d in checker.diagnostics { @@ -416,7 +425,7 @@ fn get_checks_rmd(contents: &str, file: &Path, config: &Config) -> Result = checker diff --git a/crates/jarl-core/src/lints/base/mod.rs b/crates/jarl-core/src/lints/base/mod.rs index c7b73e34d..6ef08ee1c 100644 --- a/crates/jarl-core/src/lints/base/mod.rs +++ b/crates/jarl-core/src/lints/base/mod.rs @@ -46,5 +46,6 @@ pub(crate) mod undesirable_function; pub(crate) mod unnecessary_nesting; pub(crate) mod unreachable_code; pub(crate) mod unused_function; +pub(crate) mod unused_object; pub(crate) mod vector_logic; pub(crate) mod which_grepl; diff --git a/crates/jarl-core/src/lints/base/unused_object/mod.rs b/crates/jarl-core/src/lints/base/unused_object/mod.rs new file mode 100644 index 000000000..d665e5fe8 --- /dev/null +++ b/crates/jarl-core/src/lints/base/unused_object/mod.rs @@ -0,0 +1,826 @@ +pub(crate) mod unused_object; +#[cfg(test)] +mod tests { + use crate::utils_test::*; + use insta::assert_snapshot; + + fn snapshot_lint(code: &str) -> String { + format_diagnostics(code, "unused_object", None) + } + + #[test] + fn test_no_lint_used_variable() { + expect_no_lint("x <- 1\nprint(x)", "unused_object", None); + } + + #[test] + fn test_no_lint_variable_in_expression() { + expect_no_lint("x <- 1\ny <- x + 1\nprint(y)", "unused_object", None); + } + + #[test] + fn test_no_lint_function_definition() { + expect_no_lint("f <- function() 1", "unused_object", None); + } + + #[test] + fn test_no_lint_function_parameter() { + expect_no_lint("f <- function(x) 1", "unused_object", None); + } + + #[test] + fn test_no_lint_used_in_closure() { + expect_no_lint( + "x <- 1\nf <- function() {\n y <- x + 1\n y\n}", + "unused_object", + None, + ); + } + + #[test] + fn test_no_lint_loop_variable() { + expect_no_lint("for (i in 1:10) print(i)", "unused_object", None); + } + + #[test] + fn test_no_lint_if_else_usage() { + expect_no_lint( + "x <- 1\nif (TRUE) print(x) else print(x)", + "unused_object", + None, + ); + } + + #[test] + fn test_no_lint_super_assignment() { + expect_no_lint("f <- function() { x <<- 1 }", "unused_object", None); + } + + #[test] + fn test_no_lint_replacement_function() { + expect_no_lint( + "x <- list()\nnames(x) <- 'a'\nprint(x)", + "unused_object", + None, + ); + } + + #[test] + fn test_no_lint_subset_replacement() { + expect_no_lint("x <- 1:3\nx[1] <- 10\nprint(x)", "unused_object", None); + } + + #[test] + fn test_no_lint_dollar_replacement() { + expect_no_lint("x <- list()\nx$a <- 1\nprint(x)", "unused_object", None); + } + + #[test] + fn test_no_lint_string_interpolation() { + expect_no_lint("x <- 1\nmessage(\"value is {x}\")", "unused_object", None); + } + + #[test] + fn test_no_lint_string_interpolation_expression() { + expect_no_lint( + "n <- 10\nmessage(\"{n} items found\")", + "unused_object", + None, + ); + } + + #[test] + fn test_no_lint_string_interpolation_nested_call() { + expect_no_lint("x <- 1\nglue::glue(\"{mean(x)}\")", "unused_object", None); + } + + #[test] + fn test_no_lint_string_interpolation_dollar_access() { + // `x` is referenced (used); `a` is a field name, not a binding. + expect_no_lint( + "x <- list(a = 1)\nglue::glue(\"{x$a}\")", + "unused_object", + None, + ); + } + + #[test] + fn test_no_lint_returned_by_function() { + expect_no_lint("f <- function() {\n x <- 1\n x\n}", "unused_object", None); + } + + #[test] + fn test_no_lint_used_as_argument() { + expect_no_lint("x <- 1\nmean(x)", "unused_object", None); + } + + #[test] + fn test_no_lint_used_as_named_argument() { + expect_no_lint("x <- 1\nfoo(value = x)", "unused_object", None); + } + + #[test] + fn test_no_lint_self_read_suppression() { + expect_no_lint("x <- 1\nx <- x + 1\nprint(x)", "unused_object", None); + } + + #[test] + fn test_no_lint_pipe() { + expect_no_lint("x <- 1\nx |> print()", "unused_object", None); + } + + #[test] + fn test_no_lint_used_in_condition() { + expect_no_lint("x <- TRUE\nif (x) print('yes')", "unused_object", None); + } + + #[test] + fn test_no_lint_used_in_while() { + expect_no_lint("x <- TRUE\nwhile (x) { x <- FALSE }", "unused_object", None); + } + + #[test] + fn test_no_lint_right_assignment_used() { + expect_no_lint("1 -> x\nprint(x)", "unused_object", None); + } + + #[test] + fn test_no_lint_equals_assignment_used() { + expect_no_lint("x = 1\nprint(x)", "unused_object", None); + } + + #[test] + fn test_no_lint_multiple_all_used() { + expect_no_lint( + "x <- 1 + y <- 2 + z <- x + y + print(z)", + "unused_object", + None, + ); + } + + #[test] + fn test_no_lint_used_in_nested_call() { + expect_no_lint( + " + x <- 1 + print(mean(x))", + "unused_object", + None, + ); + } + + #[test] + fn test_no_lint_local_scope() { + expect_no_lint( + " + local({ + x <- 1 + print(x) + })", + "unused_object", + None, + ); + } + + #[test] + fn test_no_lint_with_unresolved_refs_in_function_def_resolved_later() { + expect_no_lint( + " + f <- function() x + x <- 1", + "unused_object", + None, + ); + } + + #[test] + fn test_no_lint_closure_reads_redefined_variable() { + // Both definitions of `x` are read by `f()` at different call sites. + expect_no_lint( + " + x <- 1 + f <- function() x + f() + x <- 2 + f()", + "unused_object", + None, + ); + } + + #[test] + fn test_no_lint_nested_closure_reads_redefined_variable() { + // Same as test_no_lint_closure_reads_redefined_variable but nested. + expect_no_lint( + " + foo <- function() { + x <- 1 + f <- function() x + f() + x <- 2 + f() + }", + "unused_object", + None, + ); + } + + #[test] + fn test_lint_closure_only_called_after_redefinition() { + // `x <- 1` is unused because `f()` is only called after `x <- 2`. + assert_snapshot!( + snapshot_lint(" +x <- 1 +f <- function() x +x <- 2 +f()"), + @" + warning: unused_object + --> :2:1 + | + 2 | x <- 1 + | - Object `x` is defined but never used. + | + Found 1 error. + " + ); + } + + #[test] + fn test_no_lint_nested_closure_callback() { + // x is captured by f2 and used via lapply (not a direct call). + expect_no_lint( + " + f <- function() { + x <- 1 + f2 <- function(i) { + i == x + } + lapply(1:2, f2) + }", + "unused_object", + None, + ); + } + + #[test] + fn test_no_lint_anonymous_closure_callback() { + // x is captured by an anonymous function passed to lapply. + expect_no_lint( + " + x <- 1 + lapply(1, function() x)", + "unused_object", + None, + ); + // Same but nested inside a function. + expect_no_lint( + " + f <- function() { + x <- 1 + lapply(1, function() x) + }", + "unused_object", + None, + ); + } + + #[test] + fn test_no_lint_returned_closure() { + // x is captured by f2, which is returned from f. f2 could be called + // by f's caller, so x must be considered used. + // + // This happens in function factories, see for instance `string_magic_alias()` + // in stringmagic. + expect_no_lint( + " + f <- function() { + x <- 1 + f2 <- function() x + f2 + }", + "unused_object", + None, + ); + // Same but with an anonymous function as the return value. + expect_no_lint( + " + f <- function() { + x <- 1 + function() x + }", + "unused_object", + None, + ); + } + + #[test] + fn test_with_on_exit() { + // no lint when on.exit() refers to objects defined after it's called + expect_no_lint( + " + f <- function() { + on.exit(print(a)) + a <- 1 + 'hi' + } + ", + "unused_object", + None, + ); + + // See comment in `process_call()` + expect_no_lint( + " + f <- function() { + foo <- TRUE + on.exit( + if (foo) print('bye') + ) + # + foo <- FALSE + } + ", + "unused_object", + None, + ); + // report when on.exit() doesn't use objects + assert_snapshot!( + snapshot_lint(" +f <- function() { + foo <- TRUE + on.exit(print('bye')) + foo <- FALSE +} + "), + @" + warning: unused_object + --> :3:5 + | + 3 | foo <- TRUE + | --- Object `foo` is defined but never used. + | + warning: unused_object + --> :5:5 + | + 5 | foo <- FALSE + | --- Object `foo` is defined but never used. + | + Found 2 errors. + " + ); + } + + // --------------------------------------------------------------- + // Lint cases + // --------------------------------------------------------------- + + #[test] + fn test_lint_simple_unused() { + assert_snapshot!( + snapshot_lint("x <- 1\nprint(y)"), + @r" + warning: unused_object + --> :1:1 + | + 1 | x <- 1 + | - Object `x` is defined but never used. + | + Found 1 error. + " + ); + assert_snapshot!( + snapshot_lint(".x <- 1\nprint(y)"), + @" + warning: unused_object + --> :1:1 + | + 1 | .x <- 1 + | -- Object `.x` is defined but never used. + | + Found 1 error. + " + ); + } + + #[test] + fn test_lint_unused_after_reassignment() { + assert_snapshot!( + snapshot_lint("x <- 1\nx <- 2\nprint(x)"), + @r" + warning: unused_object + --> :1:1 + | + 1 | x <- 1 + | - Object `x` is defined but never used. + | + Found 1 error. + " + ); + } + + #[test] + fn test_lint_multiple_unused() { + assert_snapshot!( + snapshot_lint("x <- 1\ny <- 2"), + @r" + warning: unused_object + --> :1:1 + | + 1 | x <- 1 + | - Object `x` is defined but never used. + | + warning: unused_object + --> :2:1 + | + 2 | y <- 2 + | - Object `y` is defined but never used. + | + Found 2 errors. + " + ); + } + + #[test] + fn test_lint_unused_right_assignment() { + assert_snapshot!( + snapshot_lint("1 -> x"), + @r" + warning: unused_object + --> :1:6 + | + 1 | 1 -> x + | - Object `x` is defined but never used. + | + Found 1 error. + " + ); + } + + #[test] + fn test_lint_unused_equals_assignment() { + assert_snapshot!( + snapshot_lint("x = 1"), + @r" + warning: unused_object + --> :1:1 + | + 1 | x = 1 + | - Object `x` is defined but never used. + | + Found 1 error. + " + ); + } + + #[test] + fn test_lint_only_one_of_two_used() { + assert_snapshot!( + snapshot_lint("x <- 1\ny <- 2\nprint(x)"), + @r" + warning: unused_object + --> :2:1 + | + 2 | y <- 2 + | - Object `y` is defined but never used. + | + Found 1 error. + " + ); + } + + #[test] + fn test_lint_unused_in_function_body() { + assert_snapshot!( + snapshot_lint("f <- function() {\n x <- 1\n y <- 2\n y\n}"), + @r" + warning: unused_object + --> :2:3 + | + 2 | x <- 1 + | - Object `x` is defined but never used. + | + Found 1 error. + " + ); + } + + #[test] + fn test_lint_unused_with_used_neighbor() { + assert_snapshot!( + snapshot_lint("a <- 1\nb <- 2\nc <- a + b\nd <- 99"), + @r" + warning: unused_object + --> :3:1 + | + 3 | c <- a + b + | - Object `c` is defined but never used. + | + warning: unused_object + --> :4:1 + | + 4 | d <- 99 + | - Object `d` is defined but never used. + | + Found 2 errors. + " + ); + } + + #[test] + fn test_lint_nse_read_does_not_count() { + assert_snapshot!( + snapshot_lint("x <- 1\nquote(x)"), + @r" + warning: unused_object + --> :1:1 + | + 1 | x <- 1 + | - Object `x` is defined but never used. + | + Found 1 error. + " + ); + } + + #[test] + fn test_with_assignment_pipe() { + // should lint: re-assigned `x` isn't used + assert_snapshot!( + snapshot_lint(" +x <- 1:3 +x %<>% sum()" + ), + @" + warning: unused_object + --> :3:1 + | + 3 | x %<>% sum() + | - Object `x` is defined but never used. + | + Found 1 error. + " + ); + // shouldn't lint + assert_snapshot!( + snapshot_lint(" +x <- 1:3 +x %<>% sum() +x + 1" + ), + @"All checks passed!" + ); + } + + #[test] + fn test_assign() { + // TODO: this should report env + // shouldn't lint: env is used as argument to assign() + expect_no_lint( + " +f <- function() { + env <- new.env() + assign('x', 1 + 1, envir = env) +} +f()", + "unused_object", + None, + ); + // shouldn't lint: we return env, which contains x + expect_no_lint( + " +f <- function() { + env <- new.env() + assign('x', 1 + 1, envir = env) + env +} +f()", + "unused_object", + None, + ); + // shouldn't lint: we use env outside the function + expect_no_lint( + " +env <- new.env() +f <- function() { + assign('x', 1 + 1, envir = env) +} +f() +env", + "unused_object", + None, + ); + } + + #[test] + fn test_delayed_assign() { + // TODO: this should report env + // shouldn't lint: env is used as argument to delayedAssign() + expect_no_lint( + " +f <- function() { + env <- new.env() + delayedAssign('x', 1 + 1, assign.env = env) +} +f()", + "unused_object", + None, + ); + // shouldn't lint: we return env, which contains x + expect_no_lint( + " +f <- function() { + env <- new.env() + delayedAssign('x', 1 + 1, assign.env = env) + env +} +f()", + "unused_object", + None, + ); + // shouldn't lint: we use env outside the function + expect_no_lint( + " +env <- new.env() +f <- function() { + delayedAssign('x', 1 + 1, assign.env = env) +} +f() +env", + "unused_object", + None, + ); + } + + #[test] + fn test_make_active_binding() { + // TODO: this should report env + // shouldn't lint: env is used as argument to makeActiveBinding() + expect_no_lint( + " +f <- function() { + env <- new.env() + makeActiveBinding('x', \\(x) x, env = env) +} +f()", + "unused_object", + None, + ); + // shouldn't lint: we return env, which contains x + expect_no_lint( + " +f <- function() { + env <- new.env() + makeActiveBinding('x', \\(x) x, env = env) + env +} +f()", + "unused_object", + None, + ); + // shouldn't lint: we use env outside the function + expect_no_lint( + " +env <- new.env() +f <- function() { + makeActiveBinding('x', \\(x) x, env = env) +} +f() +env", + "unused_object", + None, + ); + } + + #[test] + fn test_dot_dot_prefix_data_table() { + expect_no_lint( + " +cols <- 'a' +dt[, ..cols] +", + "unused_object", + None, + ); + } + + #[test] + fn test_shadowing_after_condition() { + // `x <- 2` wouldn't run if the first condition is true, so `x <- 1` + // might be used. + expect_no_lint( + " +x <- 1 +if (runif(1) < 0.5 || (x <- 2)) { + print(x) +}", + "unused_object", + None, + ); + // `x <- 2` wouldn't run if the first condition is false, so `x <- 1` + // might be used. + expect_no_lint( + " +x <- 1 +if (runif(1) < 0.5 && (x <- 2)) { + 1 + 1 +} +x", + "unused_object", + None, + ); + } + + #[test] + fn test_object_used_in_next_iteration() { + expect_no_lint( + " +for (i in 1:3) { + out <- f(i, x) + x <- nrow(out) +}", + "unused_object", + None, + ); + } + + #[test] + fn test_function_def_default_arg_value() { + expect_no_lint( + " +default <- 'a' +f <- function(arg = default) {}", + "unused_object", + None, + ); + expect_no_lint( + " +f <- function(arg = default) {} +default <- 'a'", + "unused_object", + None, + ); + } + + #[test] + fn test_unused_for_loop_index_not_reported() { + expect_no_lint( + " +for (i in 1:2) { + print('hello') +}", + "unused_object", + None, + ); + } + + #[test] + fn test_rm_in_on_exit() { + expect_no_lint( + " + f <- function() { + on.exit({ + x <- 1 + rm(x) + }) + }", + "unused_object", + None, + ); + } + + #[test] + fn test_nse_in_same_call() { + expect_no_lint( + " + x <- 1 + f(x, substitute('a'))", + "unused_object", + None, + ); + } + + #[test] + fn test_special_functions_use_quoted_objects() { + expect_no_lint( + " + f <- mean + do.call('f', list(x = 1:3))", + "unused_object", + None, + ); + } + + #[test] + fn test_equal_in_formula_is_not_definition() { + expect_no_lint( + " + a ~ b + (c = 1)", + "unused_object", + None, + ); + } +} diff --git a/crates/jarl-core/src/lints/base/unused_object/unused_object.rs b/crates/jarl-core/src/lints/base/unused_object/unused_object.rs new file mode 100644 index 000000000..0d9cc0a14 --- /dev/null +++ b/crates/jarl-core/src/lints/base/unused_object/unused_object.rs @@ -0,0 +1,870 @@ +use std::collections::{HashMap, HashSet}; + +use air_r_parser::RParserOptions; +use air_r_syntax::{ + AnyRArgumentName, AnyRExpression, RArgument, RArgumentList, RBinaryExpression, RCall, + RExtractExpression, RNamespaceExpression, RSyntaxKind, RSyntaxNode, +}; +use biome_rowan::{AstNode, AstSeparatedList, SyntaxNodeCast, TextRange, TextSize}; +use oak_core::syntax_ext::RIdentifierExt; +use oak_index::DefinitionId; +use oak_index::semantic_index::{Definition, DefinitionKind, ScopeId, SemanticIndex, SymbolId}; + +use crate::checker::Checker; +use crate::diagnostic::{Diagnostic, Fix, ViolationData}; + +/// Version added: 0.6.0 +/// +/// ## What it does +/// +/// Detects local variables assigned a value that is never read. Operates on +/// oak's per-file `SemanticIndex`: walks every scope, looks at each +/// definition, and emits a warning when no `Use` reaches it (directly or +/// through a closure). +/// +/// ## Why is this bad? +/// +/// Unused assignments are usually a sign of dead code or a bug. Removing them +/// reduces noise. +/// +/// ## Examples +/// +/// ```r +/// x <- 1 # unused +/// print(y) +/// ``` +pub fn unused_object( + expressions: &[RSyntaxNode], + semantic: &SemanticIndex, + checker: &mut Checker, +) -> anyhow::Result<()> { + let mut ctx = Ctx::new(semantic); + ctx.collect_ast_passes(expressions); + + let scopes = scope_ids(semantic); + ctx.precompute_closure_uses(&scopes); + + let mut diagnostics = ctx.collect_diagnostics(&scopes); + diagnostics.extend(ctx.collect_assignment_pipe_diagnostics(expressions)); + + for d in diagnostics { + checker.report_diagnostic(Some(d)); + } + + Ok(()) +} + +fn scope_ids(index: &SemanticIndex) -> Vec { + let mut ids = Vec::new(); + let mut stack = vec![ScopeId::from(0)]; + while let Some(s) = stack.pop() { + ids.push(s); + stack.extend(index.child_scopes(s)); + } + ids +} + +struct Ctx<'a> { + index: &'a SemanticIndex, + /// Names that have a synthetic use from AST passes (string interpolation, + /// `do.call("f", …)`, `..cols`, etc.). These short-circuit reports. + synthetic_used_names: HashSet, + /// Identifier `Use` ranges that should be ignored because they sit inside + /// an NSE call argument (`quote(x)`, `substitute(…)`, `bquote(…)`, …). + nse_ranges: Vec, + /// Bodies of `local({...})` calls. A definition inside one body that's + /// also read inside the same body counts as used (oak doesn't model + /// `local()` as an eager scope yet). + local_body_ranges: Vec, + /// Ranges of formula RHSes (`~ rhs`). `=` inside a formula is named-arg + /// syntax, not assignment. + formula_ranges: Vec, + /// Definitions reachable through call-site analysis of nested closures. + /// Populated by `precompute_closure_uses`. If `(scope_id, def_id)` is + /// in this set, treat the definition as used by a closure call site. + closure_used_defs: HashSet<(ScopeId, DefinitionId)>, + /// Definitions in scopes containing an "escaped" closure: any closure + /// referenced as something other than a direct call (returned, passed + /// as argument, anonymous), forces all enclosing-symbol defs to be + /// considered used. + closure_escaped_symbols: HashSet<(ScopeId, SymbolId)>, + /// Identifier ranges that appear as the callee of an `R_CALL`. Computed + /// from the AST walk; consulted by call-site analysis. + callee_ranges: Vec, +} + +impl<'a> Ctx<'a> { + fn new(index: &'a SemanticIndex) -> Self { + Self { + index, + synthetic_used_names: HashSet::new(), + nse_ranges: Vec::new(), + local_body_ranges: Vec::new(), + formula_ranges: Vec::new(), + closure_used_defs: HashSet::new(), + closure_escaped_symbols: HashSet::new(), + callee_ranges: Vec::new(), + } + } + + fn collect_ast_passes(&mut self, expressions: &[RSyntaxNode]) { + for expr in expressions { + for node in expr.descendants() { + self.visit_node(&node); + } + } + } + + fn visit_node(&mut self, node: &RSyntaxNode) { + match node.kind() { + RSyntaxKind::R_STRING_VALUE => self.collect_string_interpolation(node), + RSyntaxKind::R_CALL => { + if let Some(call) = node.clone().cast::() { + self.visit_call(&call); + } + } + RSyntaxKind::R_DOT_DOT_I => self.collect_dotdot_identifier(node), + RSyntaxKind::R_IDENTIFIER => self.collect_dotdot_identifier(node), + RSyntaxKind::R_BINARY_EXPRESSION => { + if let Some(bin) = node.clone().cast::() { + self.visit_binary(&bin); + } + } + RSyntaxKind::R_WHILE_STATEMENT + | RSyntaxKind::R_FOR_STATEMENT + | RSyntaxKind::R_REPEAT_STATEMENT => { + self.collect_loop_assignment_names(node); + } + _ => {} + } + } + + /// Workaround for oak not retroactively connecting loop-body defs to + /// loop-condition uses. For any `<-`/`=`/`->` inside a loop body or + /// condition, mark the LHS name as a synthetic use so the def isn't + /// flagged. Coarse but matches the test contract. + fn collect_loop_assignment_names(&mut self, loop_node: &RSyntaxNode) { + for descendant in loop_node.descendants() { + if descendant.kind() != RSyntaxKind::R_BINARY_EXPRESSION { + continue; + } + if let Some(name) = assignment_lhs_name(&descendant) { + self.synthetic_used_names.insert(name); + } + } + } + + fn collect_string_interpolation(&mut self, node: &RSyntaxNode) { + let Some(token) = node.first_token() else { + return; + }; + let text = token.text_trimmed(); + let bytes = text.as_bytes(); + let mut i = 0; + while i < bytes.len() { + // glue's `{{` / `}}` are escaped literals, not interpolations. + if bytes[i] == b'{' && bytes.get(i + 1) == Some(&b'{') { + i += 2; + continue; + } + if bytes[i] == b'}' && bytes.get(i + 1) == Some(&b'}') { + i += 2; + continue; + } + if bytes[i] == b'{' { + let start = i + 1; + let mut depth = 1usize; + let mut end = start; + while end < bytes.len() && depth > 0 { + match bytes[end] { + b'{' => depth += 1, + b'}' => depth -= 1, + _ => {} + } + if depth > 0 { + end += 1; + } + } + if depth == 0 && end > start { + self.collect_identifiers_in_interpolation(&text[start..end]); + } + i = end + 1; + } else { + i += 1; + } + } + } + + /// Parse a glue-style `{...}` interpolation as R code and collect every + /// identifier reference. Skips the field side of `x$a` / `x@a` and the + /// namespace side of `pkg::name` — those name members, not bindings. + fn collect_identifiers_in_interpolation(&mut self, src: &str) { + let parsed = air_r_parser::parse(src, RParserOptions::default()); + if parsed.has_error() { + return; + } + for node in parsed.syntax().descendants() { + if node.kind() != RSyntaxKind::R_IDENTIFIER { + continue; + } + if is_member_name(&node) { + continue; + } + if let Some(token) = node.first_token() { + self.synthetic_used_names + .insert(token.text_trimmed().to_string()); + } + } + } + + fn collect_dotdot_identifier(&mut self, node: &RSyntaxNode) { + let Some(token) = node.first_token() else { + return; + }; + let text = token.text_trimmed(); + if let Some(stripped) = text.strip_prefix("..") + && !stripped.is_empty() + && stripped + .chars() + .next() + .is_some_and(|c| c.is_alphabetic() || c == '_' || c == '.') + { + self.synthetic_used_names.insert(stripped.to_string()); + } + } + + fn visit_binary(&mut self, bin: &RBinaryExpression) { + let Ok(op) = bin.operator() else { + return; + }; + let op_text = op.text_trimmed(); + // Formulas are `R_BINARY_EXPRESSION` with a `~` operator. + if op_text == "~" { + self.formula_ranges.push(bin.syntax().text_trimmed_range()); + self.nse_ranges.push(bin.syntax().text_trimmed_range()); + return; + } + + // Short-circuit operators: `cond || (x <- 2)` may skip the + // assignment entirely, so prior defs of `x` should remain alive. + // Oak walks linearly and shadows them. Workaround: any LHS + // assigned inside a `||`/`&&` is considered synthetically used, + // which stops us from flagging earlier defs of the same name. + if op_text == "||" || op_text == "&&" || op_text == "|" || op_text == "&" { + for descendant in bin.syntax().descendants() { + if descendant.kind() == RSyntaxKind::R_BINARY_EXPRESSION + && let Some(name) = assignment_lhs_name(&descendant) + { + self.synthetic_used_names.insert(name); + } + } + } + } + + fn visit_call(&mut self, call: &RCall) { + // Record the callee position so call-site analysis can distinguish + // `f` (call) from `f` (read/return). + if let Ok(func) = call.function() { + self.callee_ranges.push(func.syntax().text_trimmed_range()); + } + + let Some(name) = call_name(call) else { + return; + }; + + let arg_values: Vec<(Option, RSyntaxNode)> = call_args(call); + + match name.as_str() { + "quote" | "substitute" | "bquote" | "enquote" | "expression" | "Quote" => { + for (_, value) in &arg_values { + self.nse_ranges.push(value.text_trimmed_range()); + } + } + "do.call" | "match.fun" | "Recall" | "getFunction" => { + if let Some((_, first)) = arg_values.first() + && let Some(s) = string_literal_value(first) + { + self.synthetic_used_names.insert(s); + } + } + "local" => { + if let Some((_, body)) = arg_values.first() { + self.local_body_ranges.push(body.text_trimmed_range()); + } + } + // 4.3 `on.exit(body)` is a deferred body. The simplest + // approximation that satisfies the tests: any identifier read + // inside an `on.exit` body counts as a use, regardless of source + // order, because the body runs at function exit. Implement this + // by NOT marking those ranges NSE — oak already records them as + // uses, and the use-def map already considers them as such. + // The remaining gap is that source-order use-def can mark a + // definition before `on.exit` as dead even though the on.exit + // body reads it later. Patch: when an identifier is read inside + // an on.exit body, ALSO record it as a synthetic_used_names + // entry, so a same-name definition anywhere in the function is + // considered used. + "on.exit" => { + if let Some((_, body)) = arg_values.first() { + self.collect_on_exit_uses(body); + } + } + _ => {} + } + } + + fn collect_on_exit_uses(&mut self, body: &RSyntaxNode) { + for node in body.descendants() { + if node.kind() == RSyntaxKind::R_IDENTIFIER + && let Some(token) = node.first_token() + { + self.synthetic_used_names + .insert(token.text_trimmed().to_string()); + } + } + } + + fn collect_diagnostics(&self, scopes: &[ScopeId]) -> Vec { + let mut out = Vec::new(); + for &scope_id in scopes { + for (def_id, def) in self.index.definitions(scope_id).iter() { + if !self.should_lint_definition(def) { + continue; + } + if self.is_definition_used(scope_id, def_id, def) { + continue; + } + out.push(self.make_diagnostic(scope_id, def)); + } + } + out + } + + /// Workaround for oak not recognising `%<>%` as an assignment. Walk the + /// AST for `x %<>% f(...)` expressions; if no later use of `x` exists + /// in the same scope (or via closure capture), emit a synthetic + /// "unused_object" diagnostic on the LHS identifier. + fn collect_assignment_pipe_diagnostics(&self, expressions: &[RSyntaxNode]) -> Vec { + let mut out = Vec::new(); + for expr in expressions { + for node in expr.descendants() { + if node.kind() != RSyntaxKind::R_BINARY_EXPRESSION { + continue; + } + let Some(bin) = node.clone().cast::() else { + continue; + }; + let Ok(op) = bin.operator() else { continue }; + if op.text_trimmed() != "%<>%" { + continue; + } + let Ok(lhs) = bin.left() else { continue }; + if lhs.syntax().kind() != RSyntaxKind::R_IDENTIFIER { + continue; + } + let Some(ident) = lhs.syntax().clone().cast::() else { + continue; + }; + let name = ident.name_text(); + let lhs_range = lhs.syntax().text_trimmed_range(); + let expr_end = bin.syntax().text_trimmed_range().end(); + + // Find the scope containing this expression. + let (scope_id, _) = self.index.scope_at(lhs_range.start()); + + // Look for any later use of `name` in this scope (after + // `expr_end`). + let symbol = self.index.symbols(scope_id).id(&name); + let later_use = symbol.is_some_and(|sym| { + self.index + .uses(scope_id) + .iter() + .any(|(_, u)| u.symbol() == sym && u.range().start() >= expr_end) + }); + // Also check escape via closures. + let closure_use = symbol + .is_some_and(|sym| self.closure_escaped_symbols.contains(&(scope_id, sym))); + + if !later_use && !closure_use { + out.push(Diagnostic::new( + ViolationData::new( + "unused_object".to_string(), + format!("Object `{name}` is defined but never used."), + None, + ), + lhs_range, + Fix::empty(), + )); + } + } + } + out + } + + fn should_lint_definition(&self, def: &Definition) -> bool { + match def.kind() { + DefinitionKind::Parameter(_) + | DefinitionKind::ForVariable(_) + | DefinitionKind::SuperAssignment(_) + | DefinitionKind::Import { .. } => return false, + DefinitionKind::Assignment(node) => { + // 4.10 RHS is a function literal: never lint. + if assignment_rhs_is_function_def(node) { + return false; + } + // 4.1 Replacement-function or subset assignment LHS + // (e.g. `names(x) <-`, `x[1] <-`, `x$a <-`): skip — the LHS + // construct reads x and the test contract is that the + // surrounding `x <- list()` is still considered used. + if assignment_lhs_is_complex(node) { + return false; + } + } + } + + // 4.9 Formula `=` is not assignment. + if self.in_any_range(def.range(), &self.formula_ranges) { + return false; + } + + true + } + + fn is_definition_used( + &self, + scope_id: ScopeId, + def_id: DefinitionId, + def: &Definition, + ) -> bool { + let symbol_name = self.index.symbols(scope_id).symbol_id(def.symbol()).name(); + + // AST-derived synthetic uses (string interp, do.call, ..cols, on.exit) + if self.synthetic_used_names.contains(symbol_name) { + return true; + } + + if self.is_reached_by_use_in_scope(scope_id, def_id, def.symbol()) { + return true; + } + + // 4.5 Closure escape + call-site analysis. + if self.is_used_via_closure(scope_id, def_id, def.symbol()) { + return true; + } + + // 4.11 `local({…})` shortcut. + if self.is_used_inside_local_body(scope_id, def) { + return true; + } + + false + } + + fn is_reached_by_use_in_scope( + &self, + scope_id: ScopeId, + def_id: DefinitionId, + symbol: SymbolId, + ) -> bool { + let use_def = self.index.use_def_map(scope_id); + let uses = self.index.uses(scope_id); + for (use_id, u) in uses.iter() { + if u.symbol() != symbol { + continue; + } + if self.in_any_range(u.range(), &self.nse_ranges) { + continue; + } + let bindings = use_def.bindings_at_use(use_id); + if bindings.contains_definition(def_id) { + return true; + } + } + false + } + + fn is_used_via_closure( + &self, + scope_id: ScopeId, + def_id: DefinitionId, + symbol: SymbolId, + ) -> bool { + if self.closure_escaped_symbols.contains(&(scope_id, symbol)) { + return true; + } + self.closure_used_defs.contains(&(scope_id, def_id)) + } + + /// For each function scope F that's a child of P, classify F's name uses + /// in P into call sites vs. escapes. If F escapes, mark its free vars + /// in P as escaped (all defs of those names get a free pass). If F has + /// only call-site uses, find the latest reaching definition of each + /// free variable at each call site (in source order) and mark those. + fn precompute_closure_uses(&mut self, scopes: &[ScopeId]) { + // Map child Function scope → its bound name in the parent scope (if + // any) and the parent ScopeId. + let mut parent_of: HashMap = HashMap::new(); + for &scope in scopes { + for child in self.index.child_scopes(scope) { + parent_of.insert(child, scope); + } + } + + for &child in scopes { + let Some(&parent) = parent_of.get(&child) else { + continue; + }; + self.classify_closure(parent, child); + } + } + + fn classify_closure(&mut self, parent: ScopeId, child: ScopeId) { + // Find the binding name of `child` in `parent`. Heuristic: an + // assignment in `parent` whose syntax range exactly contains + // `child.range()` and whose RHS is a function definition. If we + // can't find one, treat the closure as anonymous → escape. + let child_range = self.index.scope(child).range(); + + let mut binding_name: Option = None; + for (_, def) in self.index.definitions(parent).iter() { + if let DefinitionKind::Assignment(node) = def.kind() + && node.text_trimmed_range().contains_range(child_range) + && assignment_rhs_is_function_def(node) + { + let name = self + .index + .symbols(parent) + .symbol_id(def.symbol()) + .name() + .to_string(); + binding_name = Some(name); + break; + } + } + + let Some(name) = binding_name else { + // Anonymous closure: any free variable's enclosing-scope defs + // must be conservatively considered used. + self.escape_free_vars(parent, child); + return; + }; + + // Find this name in the parent's symbol table. + let Some(parent_symbol) = self.index.symbols(parent).id(&name) else { + self.escape_free_vars(parent, child); + return; + }; + + // Collect call-site offsets and detect escapes by walking parent + // uses of `parent_symbol`. + let mut call_offsets: Vec = Vec::new(); + let mut escaped = false; + for (_, u) in self.index.uses(parent).iter() { + if u.symbol() != parent_symbol { + continue; + } + if self.use_is_callee(u.range()) { + call_offsets.push(u.range().end()); + } else { + escaped = true; + break; + } + } + + if escaped || call_offsets.is_empty() { + // If no calls, the closure is bound but never invoked here. + // Conservative: treat as escaped (could be returned implicitly, + // exported, etc.). This matches the test contract for + // `f <- function() { x <- 1; f2 <- function() x; f2 }` where + // f2 is returned and we want to keep `x <- 1` alive. + self.escape_free_vars(parent, child); + return; + } + + // For each free variable of `child`: find reaching defs in `parent` + // at each call offset, and mark them used. + let free_vars = self.free_variables(child, parent); + for parent_sym in free_vars { + let parent_defs: Vec<(DefinitionId, TextSize)> = self + .index + .definitions(parent) + .iter() + .filter(|(_, d)| d.symbol() == parent_sym) + .map(|(id, d)| (id, d.range().end())) + .collect(); + for &offset in &call_offsets { + if let Some((def_id, _)) = parent_defs + .iter() + .filter(|(_, def_end)| *def_end <= offset) + .max_by_key(|(_, def_end)| *def_end) + { + self.closure_used_defs.insert((parent, *def_id)); + } + } + } + } + + fn escape_free_vars(&mut self, parent: ScopeId, child: ScopeId) { + for parent_sym in self.free_variables(child, parent) { + self.closure_escaped_symbols.insert((parent, parent_sym)); + } + } + + /// Names that the child scope (or any nested descendant of it) + /// references but doesn't bind locally — i.e. free variables that + /// resolve to the parent scope. Returns the corresponding parent + /// `SymbolId`s. + fn free_variables(&self, child: ScopeId, parent: ScopeId) -> Vec { + let mut out = Vec::new(); + let mut seen: HashSet = HashSet::new(); + for descendant in self.descendants(child) { + for (_, u) in self.index.uses(descendant).iter() { + let name = self + .index + .symbols(descendant) + .symbol_id(u.symbol()) + .name() + .to_string(); + if !seen.insert(name.clone()) { + continue; + } + if let Some(parent_sym) = self.index.symbols(parent).id(&name) { + out.push(parent_sym); + } + } + } + out + } + + /// Is the given identifier-use range the callee of an `R_CALL`? We + /// approximate by walking the AST starting from any descendant whose + /// range matches; cheaper than tracking node IDs. + fn use_is_callee(&self, range: TextRange) -> bool { + // We can't access the syntax root from `Ctx`. Instead, rely on the + // separately-tracked callee ranges populated during AST walk. + self.callee_ranges + .iter() + .any(|r| r.contains_range(range) || *r == range) + } + + fn is_used_inside_local_body(&self, scope_id: ScopeId, def: &Definition) -> bool { + if self.local_body_ranges.is_empty() { + return false; + } + let Some(body_range) = self + .local_body_ranges + .iter() + .find(|r| r.contains_range(def.range())) + .copied() + else { + return false; + }; + for (_, u) in self.index.uses(scope_id).iter() { + if u.symbol() != def.symbol() { + continue; + } + if body_range.contains_range(u.range()) { + return true; + } + } + false + } + + fn descendants(&self, scope_id: ScopeId) -> Vec { + let mut out = vec![scope_id]; + let mut stack = vec![scope_id]; + while let Some(s) = stack.pop() { + for child in self.index.child_scopes(s) { + out.push(child); + stack.push(child); + } + } + out + } + + fn in_any_range(&self, target: TextRange, ranges: &[TextRange]) -> bool { + ranges.iter().any(|r| r.contains_range(target)) + } + + fn make_diagnostic(&self, scope_id: ScopeId, def: &Definition) -> Diagnostic { + let name = self + .index + .symbols(scope_id) + .symbol_id(def.symbol()) + .name() + .to_string(); + let range = lhs_range_for_definition(def).unwrap_or_else(|| def.range()); + Diagnostic::new( + ViolationData::new( + "unused_object".to_string(), + format!("Object `{name}` is defined but never used."), + None, + ), + range, + Fix::empty(), + ) + } +} + +fn is_member_name(node: &RSyntaxNode) -> bool { + let Some(parent) = node.parent() else { + return false; + }; + match parent.kind() { + RSyntaxKind::R_EXTRACT_EXPRESSION => parent + .cast::() + .and_then(|e| e.right().ok()) + .is_some_and(|r| r.syntax() == node), + RSyntaxKind::R_NAMESPACE_EXPRESSION => parent + .cast::() + .and_then(|e| e.right().ok()) + .is_some_and(|r| r.syntax() == node), + _ => false, + } +} + +fn assignment_rhs_is_function_def(node: &RSyntaxNode) -> bool { + // The RHS of a `<-` / `=` / `->` binary expression. We just look for + // any `R_FUNCTION_DEFINITION` direct child — `RBinaryExpression`'s left + // and right slots are themselves `R_FUNCTION_DEFINITION` if applicable. + for child in node.children() { + if child.kind() == RSyntaxKind::R_FUNCTION_DEFINITION { + return true; + } + } + false +} + +fn assignment_lhs_is_complex(node: &RSyntaxNode) -> bool { + // For binary assignment, peek at the LHS and treat anything other than + // a bare identifier as "complex" (e.g. `names(x)`, `x[1]`, `x$a`). + // For `->`/`->>` assignments the LHS is the rightmost side; we treat + // those identically. + let Some(bin) = node.clone().cast::() else { + return false; + }; + let Ok(op) = bin.operator() else { + return false; + }; + let lhs = if op.text_trimmed() == "->" || op.text_trimmed() == "->>" { + bin.right().ok().map(|n| n.syntax().clone()) + } else { + bin.left().ok().map(|n| n.syntax().clone()) + }; + match lhs { + Some(node) => !matches!(node.kind(), RSyntaxKind::R_IDENTIFIER), + None => false, + } +} + +fn lhs_range_for_definition(def: &Definition) -> Option { + let node = match def.kind() { + DefinitionKind::Assignment(n) | DefinitionKind::SuperAssignment(n) => n, + DefinitionKind::Parameter(n) | DefinitionKind::ForVariable(n) => { + return Some(n.text_trimmed_range()); + } + DefinitionKind::Import { .. } => return None, + }; + let bin = node.clone().cast::()?; + let op = bin.operator().ok()?; + let lhs = if op.text_trimmed() == "->" || op.text_trimmed() == "->>" { + bin.right().ok()? + } else { + bin.left().ok()? + }; + let lhs_node = lhs.syntax(); + if lhs_node.kind() == RSyntaxKind::R_IDENTIFIER { + Some(lhs_node.text_trimmed_range()) + } else { + // Fall back to the whole assignment range if the LHS isn't a + // simple identifier. + None + } +} + +fn call_name(call: &RCall) -> Option { + let func = call.function().ok()?; + match func { + AnyRExpression::RIdentifier(ident) => Some(ident.name_text()), + AnyRExpression::RNamespaceExpression(ns) => { + // Match on the right-hand identifier (`pkg::name` ⇒ `name`). + ns.right() + .ok() + .and_then(|r| r.syntax().first_token()) + .map(|t| t.text_trimmed().to_string()) + } + _ => None, + } +} + +fn call_args(call: &RCall) -> Vec<(Option, RSyntaxNode)> { + let Ok(arguments) = call.arguments() else { + return Vec::new(); + }; + let items = arguments.items(); + args_iter(&items) +} + +fn args_iter(list: &RArgumentList) -> Vec<(Option, RSyntaxNode)> { + let mut out = Vec::new(); + for item in list.iter() { + let Ok(arg) = item else { continue }; + let name = argument_name(&arg); + let value = arg.value().map(|v| v.syntax().clone()); + if let Some(value) = value { + out.push((name, value)); + } + } + out +} + +/// Return the LHS identifier name of a binary assignment expression +/// (`x <- …`, `x = …`, `… -> x`, `x <<- …`, `… ->> x`). Returns None for +/// any other binary expression or for assignments whose LHS isn't a bare +/// identifier. +fn assignment_lhs_name(node: &RSyntaxNode) -> Option { + let bin = node.clone().cast::()?; + let op = bin.operator().ok()?; + let op_text = op.text_trimmed(); + let lhs = match op_text { + "<-" | "<<-" | "=" => bin.left().ok()?, + "->" | "->>" => bin.right().ok()?, + _ => return None, + }; + let node = lhs.syntax(); + if node.kind() == RSyntaxKind::R_IDENTIFIER { + let ident = node.clone().cast::()?; + Some(ident.name_text()) + } else { + None + } +} + +fn argument_name(arg: &RArgument) -> Option { + let clause = arg.name_clause()?; + let name = clause.name().ok()?; + match name { + AnyRArgumentName::RIdentifier(ident) => Some(ident.name_text()), + AnyRArgumentName::RDots(_) => Some("...".to_string()), + _ => None, + } +} + +fn string_literal_value(node: &RSyntaxNode) -> Option { + if node.kind() != RSyntaxKind::R_STRING_VALUE { + return None; + } + let token = node.first_token()?; + let text = token.text_trimmed(); + let bytes = text.as_bytes(); + if bytes.len() < 2 { + return None; + } + let first = bytes[0]; + let last = *bytes.last().unwrap(); + if (first == b'"' || first == b'\'') && first == last { + Some(text[1..text.len() - 1].to_string()) + } else { + None + } +} diff --git a/crates/jarl-core/src/rule_set.rs b/crates/jarl-core/src/rule_set.rs index aaecfe32f..8620f5313 100644 --- a/crates/jarl-core/src/rule_set.rs +++ b/crates/jarl-core/src/rule_set.rs @@ -580,6 +580,13 @@ declare_rules! { fix: None, min_r_version: None, }, + UnusedObject => { + name: "unused_object", + categories: [Corr], + default: Enabled, + fix: None, + min_r_version: None, + }, VectorLogic => { name: "vector_logic", categories: [Perf], From 2e658d42813501d57800e6d73fc6a328d2af05dd Mon Sep 17 00:00:00 2001 From: etiennebacher Date: Sun, 10 May 2026 19:43:47 +0100 Subject: [PATCH 02/39] better handling of exports --- .../lints/base/unused_object/unused_object.rs | 25 +++++- crates/jarl/tests/integration/main.rs | 1 + .../jarl/tests/integration/unused_object.rs | 89 +++++++++++++++++++ 3 files changed, 112 insertions(+), 3 deletions(-) create mode 100644 crates/jarl/tests/integration/unused_object.rs diff --git a/crates/jarl-core/src/lints/base/unused_object/unused_object.rs b/crates/jarl-core/src/lints/base/unused_object/unused_object.rs index 0d9cc0a14..095b8a8d9 100644 --- a/crates/jarl-core/src/lints/base/unused_object/unused_object.rs +++ b/crates/jarl-core/src/lints/base/unused_object/unused_object.rs @@ -38,7 +38,7 @@ pub fn unused_object( semantic: &SemanticIndex, checker: &mut Checker, ) -> anyhow::Result<()> { - let mut ctx = Ctx::new(semantic); + let mut ctx = Ctx::new(semantic, &checker.namespace_exports); ctx.collect_ast_passes(expressions); let scopes = scope_ids(semantic); @@ -66,6 +66,10 @@ fn scope_ids(index: &SemanticIndex) -> Vec { struct Ctx<'a> { index: &'a SemanticIndex, + /// Names exported by the package's NAMESPACE. A top-level definition with + /// one of these names is "used" by external callers and must not be + /// flagged, even when nothing in the file reads it. + exports: &'a HashSet, /// Names that have a synthetic use from AST passes (string interpolation, /// `do.call("f", …)`, `..cols`, etc.). These short-circuit reports. synthetic_used_names: HashSet, @@ -94,9 +98,10 @@ struct Ctx<'a> { } impl<'a> Ctx<'a> { - fn new(index: &'a SemanticIndex) -> Self { + fn new(index: &'a SemanticIndex, exports: &'a HashSet) -> Self { Self { index, + exports, synthetic_used_names: HashSet::new(), nse_ranges: Vec::new(), local_body_ranges: Vec::new(), @@ -326,6 +331,7 @@ impl<'a> Ctx<'a> { fn collect_diagnostics(&self, scopes: &[ScopeId]) -> Vec { let mut out = Vec::new(); + let top_level = ScopeId::from(0); for &scope_id in scopes { for (def_id, def) in self.index.definitions(scope_id).iter() { if !self.should_lint_definition(def) { @@ -334,12 +340,23 @@ impl<'a> Ctx<'a> { if self.is_definition_used(scope_id, def_id, def) { continue; } + if scope_id == top_level && self.is_exported(scope_id, def) { + continue; + } out.push(self.make_diagnostic(scope_id, def)); } } out } + fn is_exported(&self, scope_id: ScopeId, def: &Definition) -> bool { + if self.exports.is_empty() { + return false; + } + let name = self.index.symbols(scope_id).symbol_id(def.symbol()).name(); + self.exports.contains(name) + } + /// Workaround for oak not recognising `%<>%` as an assignment. Walk the /// AST for `x %<>% f(...)` expressions; if no later use of `x` exists /// in the same scope (or via closure capture), emit a synthetic @@ -384,8 +401,10 @@ impl<'a> Ctx<'a> { // Also check escape via closures. let closure_use = symbol .is_some_and(|sym| self.closure_escaped_symbols.contains(&(scope_id, sym))); + // Top-level exported names are used by external callers. + let exported = scope_id == ScopeId::from(0) && self.exports.contains(&name); - if !later_use && !closure_use { + if !later_use && !closure_use && !exported { out.push(Diagnostic::new( ViolationData::new( "unused_object".to_string(), diff --git a/crates/jarl/tests/integration/main.rs b/crates/jarl/tests/integration/main.rs index e58c0c669..e39ad0d40 100644 --- a/crates/jarl/tests/integration/main.rs +++ b/crates/jarl/tests/integration/main.rs @@ -27,3 +27,4 @@ mod statistics; mod toml; mod toml_hierarchical; mod toml_rule_args; +mod unused_object; diff --git a/crates/jarl/tests/integration/unused_object.rs b/crates/jarl/tests/integration/unused_object.rs new file mode 100644 index 000000000..d317f0556 --- /dev/null +++ b/crates/jarl/tests/integration/unused_object.rs @@ -0,0 +1,89 @@ +use crate::helpers::{CliTest, CommandExt}; + +#[test] +fn test_exported_alias_not_flagged() -> anyhow::Result<()> { + // `summarize_each <- summarise_each` is a typical alias-style export. The + // RHS isn't a function literal, so the existing function-def filter + // doesn't suppress it; we rely on the NAMESPACE export list instead. + let case = CliTest::with_files([ + ( + "DESCRIPTION", + "Package: testpkg\nTitle: Test\nVersion: 0.0.1\n", + ), + ("NAMESPACE", "export(summarize_each)\n"), + ( + "R/aliases.R", + "summarise_each <- function(x) x\nsummarize_each <- summarise_each\n", + ), + ])?; + + insta::assert_snapshot!( + &mut case + .command() + .arg("check") + .arg(".") + .arg("--select") + .arg("unused_object") + .run() + .normalize_os_executable_name(), + @" + + success: true + exit_code: 0 + ----- stdout ----- + ── Summary ────────────────────────────────────── + All checks passed! + + ----- stderr ----- + " + ); + + Ok(()) +} + +#[test] +fn test_unexported_alias_is_flagged() -> anyhow::Result<()> { + // Same code, but no NAMESPACE export — `summarize_each` is dead. + let case = CliTest::with_files([ + ( + "DESCRIPTION", + "Package: testpkg\nTitle: Test\nVersion: 0.0.1\n", + ), + ("NAMESPACE", ""), + ( + "R/aliases.R", + "summarise_each <- function(x) x\nsummarize_each <- summarise_each\n", + ), + ])?; + + insta::assert_snapshot!( + &mut case + .command() + .arg("check") + .arg(".") + .arg("--select") + .arg("unused_object") + .run() + .normalize_os_executable_name(), + @r" + + success: false + exit_code: 1 + ----- stdout ----- + warning: unused_object + --> R/aliases.R:2:1 + | + 2 | summarize_each <- summarise_each + | -------------- Object `summarize_each` is defined but never used. + | + + + ── Summary ────────────────────────────────────── + Found 1 error. + + ----- stderr ----- + " + ); + + Ok(()) +} From e26177134f62f4237db04ab203d9ac54272cac28 Mon Sep 17 00:00:00 2001 From: etiennebacher Date: Mon, 11 May 2026 21:29:48 +0100 Subject: [PATCH 03/39] create new subcrate jarl-semantic --- Cargo.lock | 13 + Cargo.toml | 1 + crates/jarl-core/Cargo.toml | 3 + .../lints/base/unused_object/unused_object.rs | 939 +++--------------- crates/jarl-semantic/Cargo.toml | 22 + crates/jarl-semantic/src/lib.rs | 684 +++++++++++++ 6 files changed, 843 insertions(+), 819 deletions(-) create mode 100644 crates/jarl-semantic/Cargo.toml create mode 100644 crates/jarl-semantic/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 90c783d05..a38d8e7b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1160,6 +1160,7 @@ dependencies = [ "fs", "ignore", "insta", + "jarl-semantic", "oak_core", "oak_index", "oak_index_vec", @@ -1194,6 +1195,18 @@ dependencies = [ "workspace", ] +[[package]] +name = "jarl-semantic" +version = "0.5.0" +dependencies = [ + "air_r_parser", + "air_r_syntax", + "biome_rowan", + "oak_core", + "oak_index", + "oak_index_vec", +] + [[package]] name = "js-sys" version = "0.3.98" diff --git a/Cargo.toml b/Cargo.toml index 54ab6d2be..6cc040a57 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,7 @@ insta = { version = "1.47.2", features = ["yaml"] } etcetera = "0.11.0" jarl-core = { path = "crates/jarl-core" } jarl-lsp = { path = "crates/jarl-lsp" } +jarl-semantic = { path = "crates/jarl-semantic" } path-absolutize = "3.1.1" rayon = "1.12.0" regex = { version = "1.11.1", default-features = false, features = ["std"] } diff --git a/crates/jarl-core/Cargo.toml b/crates/jarl-core/Cargo.toml index 0e3d2c2f1..42c8e8915 100644 --- a/crates/jarl-core/Cargo.toml +++ b/crates/jarl-core/Cargo.toml @@ -29,6 +29,9 @@ oak_index.workspace = true oak_index_vec.workspace = true url.workspace = true +# Per-file semantic facts (used by DFG-based lints) +jarl-semantic.workspace = true + # Serialization and data structures serde.workspace = true toml.workspace = true diff --git a/crates/jarl-core/src/lints/base/unused_object/unused_object.rs b/crates/jarl-core/src/lints/base/unused_object/unused_object.rs index 095b8a8d9..03fc186a4 100644 --- a/crates/jarl-core/src/lints/base/unused_object/unused_object.rs +++ b/crates/jarl-core/src/lints/base/unused_object/unused_object.rs @@ -1,14 +1,12 @@ -use std::collections::{HashMap, HashSet}; +use air_r_syntax::{RBinaryExpression, RSyntaxKind, RSyntaxNode}; +use biome_rowan::{AstNode, SyntaxNodeCast}; +use oak_core::syntax_ext::RIdentifierExt; +use oak_index::semantic_index::{Definition, DefinitionKind, ScopeId, SemanticIndex}; -use air_r_parser::RParserOptions; -use air_r_syntax::{ - AnyRArgumentName, AnyRExpression, RArgument, RArgumentList, RBinaryExpression, RCall, - RExtractExpression, RNamespaceExpression, RSyntaxKind, RSyntaxNode, +use jarl_semantic::{ + SemanticFacts, assignment_lhs_is_complex, assignment_rhs_is_function_def, + lhs_range_for_definition, }; -use biome_rowan::{AstNode, AstSeparatedList, SyntaxNodeCast, TextRange, TextSize}; -use oak_core::syntax_ext::RIdentifierExt; -use oak_index::DefinitionId; -use oak_index::semantic_index::{Definition, DefinitionKind, ScopeId, SemanticIndex, SymbolId}; use crate::checker::Checker; use crate::diagnostic::{Diagnostic, Fix, ViolationData}; @@ -38,852 +36,155 @@ pub fn unused_object( semantic: &SemanticIndex, checker: &mut Checker, ) -> anyhow::Result<()> { - let mut ctx = Ctx::new(semantic, &checker.namespace_exports); - ctx.collect_ast_passes(expressions); - - let scopes = scope_ids(semantic); - ctx.precompute_closure_uses(&scopes); - - let mut diagnostics = ctx.collect_diagnostics(&scopes); - diagnostics.extend(ctx.collect_assignment_pipe_diagnostics(expressions)); - - for d in diagnostics { - checker.report_diagnostic(Some(d)); - } - - Ok(()) -} - -fn scope_ids(index: &SemanticIndex) -> Vec { - let mut ids = Vec::new(); - let mut stack = vec![ScopeId::from(0)]; - while let Some(s) = stack.pop() { - ids.push(s); - stack.extend(index.child_scopes(s)); - } - ids -} - -struct Ctx<'a> { - index: &'a SemanticIndex, - /// Names exported by the package's NAMESPACE. A top-level definition with - /// one of these names is "used" by external callers and must not be - /// flagged, even when nothing in the file reads it. - exports: &'a HashSet, - /// Names that have a synthetic use from AST passes (string interpolation, - /// `do.call("f", …)`, `..cols`, etc.). These short-circuit reports. - synthetic_used_names: HashSet, - /// Identifier `Use` ranges that should be ignored because they sit inside - /// an NSE call argument (`quote(x)`, `substitute(…)`, `bquote(…)`, …). - nse_ranges: Vec, - /// Bodies of `local({...})` calls. A definition inside one body that's - /// also read inside the same body counts as used (oak doesn't model - /// `local()` as an eager scope yet). - local_body_ranges: Vec, - /// Ranges of formula RHSes (`~ rhs`). `=` inside a formula is named-arg - /// syntax, not assignment. - formula_ranges: Vec, - /// Definitions reachable through call-site analysis of nested closures. - /// Populated by `precompute_closure_uses`. If `(scope_id, def_id)` is - /// in this set, treat the definition as used by a closure call site. - closure_used_defs: HashSet<(ScopeId, DefinitionId)>, - /// Definitions in scopes containing an "escaped" closure: any closure - /// referenced as something other than a direct call (returned, passed - /// as argument, anonymous), forces all enclosing-symbol defs to be - /// considered used. - closure_escaped_symbols: HashSet<(ScopeId, SymbolId)>, - /// Identifier ranges that appear as the callee of an `R_CALL`. Computed - /// from the AST walk; consulted by call-site analysis. - callee_ranges: Vec, -} - -impl<'a> Ctx<'a> { - fn new(index: &'a SemanticIndex, exports: &'a HashSet) -> Self { - Self { - index, - exports, - synthetic_used_names: HashSet::new(), - nse_ranges: Vec::new(), - local_body_ranges: Vec::new(), - formula_ranges: Vec::new(), - closure_used_defs: HashSet::new(), - closure_escaped_symbols: HashSet::new(), - callee_ranges: Vec::new(), - } - } - - fn collect_ast_passes(&mut self, expressions: &[RSyntaxNode]) { - for expr in expressions { - for node in expr.descendants() { - self.visit_node(&node); - } - } - } - - fn visit_node(&mut self, node: &RSyntaxNode) { - match node.kind() { - RSyntaxKind::R_STRING_VALUE => self.collect_string_interpolation(node), - RSyntaxKind::R_CALL => { - if let Some(call) = node.clone().cast::() { - self.visit_call(&call); - } - } - RSyntaxKind::R_DOT_DOT_I => self.collect_dotdot_identifier(node), - RSyntaxKind::R_IDENTIFIER => self.collect_dotdot_identifier(node), - RSyntaxKind::R_BINARY_EXPRESSION => { - if let Some(bin) = node.clone().cast::() { - self.visit_binary(&bin); - } - } - RSyntaxKind::R_WHILE_STATEMENT - | RSyntaxKind::R_FOR_STATEMENT - | RSyntaxKind::R_REPEAT_STATEMENT => { - self.collect_loop_assignment_names(node); - } - _ => {} - } - } - - /// Workaround for oak not retroactively connecting loop-body defs to - /// loop-condition uses. For any `<-`/`=`/`->` inside a loop body or - /// condition, mark the LHS name as a synthetic use so the def isn't - /// flagged. Coarse but matches the test contract. - fn collect_loop_assignment_names(&mut self, loop_node: &RSyntaxNode) { - for descendant in loop_node.descendants() { - if descendant.kind() != RSyntaxKind::R_BINARY_EXPRESSION { + let facts = SemanticFacts::build(expressions, semantic); + let exports = &checker.namespace_exports; + + let mut diagnostics = Vec::new(); + let top_level = ScopeId::from(0); + for &scope_id in &facts.scope_ids() { + for (def_id, def) in semantic.definitions(scope_id).iter() { + if !should_lint_definition(&facts, def) { continue; } - if let Some(name) = assignment_lhs_name(&descendant) { - self.synthetic_used_names.insert(name); - } - } - } - - fn collect_string_interpolation(&mut self, node: &RSyntaxNode) { - let Some(token) = node.first_token() else { - return; - }; - let text = token.text_trimmed(); - let bytes = text.as_bytes(); - let mut i = 0; - while i < bytes.len() { - // glue's `{{` / `}}` are escaped literals, not interpolations. - if bytes[i] == b'{' && bytes.get(i + 1) == Some(&b'{') { - i += 2; + if facts.is_definition_used(scope_id, def_id, def) { continue; } - if bytes[i] == b'}' && bytes.get(i + 1) == Some(&b'}') { - i += 2; + if scope_id == top_level && is_exported(semantic, exports, scope_id, def) { continue; } - if bytes[i] == b'{' { - let start = i + 1; - let mut depth = 1usize; - let mut end = start; - while end < bytes.len() && depth > 0 { - match bytes[end] { - b'{' => depth += 1, - b'}' => depth -= 1, - _ => {} - } - if depth > 0 { - end += 1; - } - } - if depth == 0 && end > start { - self.collect_identifiers_in_interpolation(&text[start..end]); - } - i = end + 1; - } else { - i += 1; - } - } - } - - /// Parse a glue-style `{...}` interpolation as R code and collect every - /// identifier reference. Skips the field side of `x$a` / `x@a` and the - /// namespace side of `pkg::name` — those name members, not bindings. - fn collect_identifiers_in_interpolation(&mut self, src: &str) { - let parsed = air_r_parser::parse(src, RParserOptions::default()); - if parsed.has_error() { - return; - } - for node in parsed.syntax().descendants() { - if node.kind() != RSyntaxKind::R_IDENTIFIER { - continue; - } - if is_member_name(&node) { - continue; - } - if let Some(token) = node.first_token() { - self.synthetic_used_names - .insert(token.text_trimmed().to_string()); - } - } - } - - fn collect_dotdot_identifier(&mut self, node: &RSyntaxNode) { - let Some(token) = node.first_token() else { - return; - }; - let text = token.text_trimmed(); - if let Some(stripped) = text.strip_prefix("..") - && !stripped.is_empty() - && stripped - .chars() - .next() - .is_some_and(|c| c.is_alphabetic() || c == '_' || c == '.') - { - self.synthetic_used_names.insert(stripped.to_string()); + diagnostics.push(make_diagnostic(semantic, scope_id, def)); } } + diagnostics.extend(collect_assignment_pipe_diagnostics( + expressions, + semantic, + &facts, + exports, + )); - fn visit_binary(&mut self, bin: &RBinaryExpression) { - let Ok(op) = bin.operator() else { - return; - }; - let op_text = op.text_trimmed(); - // Formulas are `R_BINARY_EXPRESSION` with a `~` operator. - if op_text == "~" { - self.formula_ranges.push(bin.syntax().text_trimmed_range()); - self.nse_ranges.push(bin.syntax().text_trimmed_range()); - return; - } - - // Short-circuit operators: `cond || (x <- 2)` may skip the - // assignment entirely, so prior defs of `x` should remain alive. - // Oak walks linearly and shadows them. Workaround: any LHS - // assigned inside a `||`/`&&` is considered synthetically used, - // which stops us from flagging earlier defs of the same name. - if op_text == "||" || op_text == "&&" || op_text == "|" || op_text == "&" { - for descendant in bin.syntax().descendants() { - if descendant.kind() == RSyntaxKind::R_BINARY_EXPRESSION - && let Some(name) = assignment_lhs_name(&descendant) - { - self.synthetic_used_names.insert(name); - } - } - } + for d in diagnostics { + checker.report_diagnostic(Some(d)); } - fn visit_call(&mut self, call: &RCall) { - // Record the callee position so call-site analysis can distinguish - // `f` (call) from `f` (read/return). - if let Ok(func) = call.function() { - self.callee_ranges.push(func.syntax().text_trimmed_range()); - } - - let Some(name) = call_name(call) else { - return; - }; - - let arg_values: Vec<(Option, RSyntaxNode)> = call_args(call); - - match name.as_str() { - "quote" | "substitute" | "bquote" | "enquote" | "expression" | "Quote" => { - for (_, value) in &arg_values { - self.nse_ranges.push(value.text_trimmed_range()); - } - } - "do.call" | "match.fun" | "Recall" | "getFunction" => { - if let Some((_, first)) = arg_values.first() - && let Some(s) = string_literal_value(first) - { - self.synthetic_used_names.insert(s); - } - } - "local" => { - if let Some((_, body)) = arg_values.first() { - self.local_body_ranges.push(body.text_trimmed_range()); - } - } - // 4.3 `on.exit(body)` is a deferred body. The simplest - // approximation that satisfies the tests: any identifier read - // inside an `on.exit` body counts as a use, regardless of source - // order, because the body runs at function exit. Implement this - // by NOT marking those ranges NSE — oak already records them as - // uses, and the use-def map already considers them as such. - // The remaining gap is that source-order use-def can mark a - // definition before `on.exit` as dead even though the on.exit - // body reads it later. Patch: when an identifier is read inside - // an on.exit body, ALSO record it as a synthetic_used_names - // entry, so a same-name definition anywhere in the function is - // considered used. - "on.exit" => { - if let Some((_, body)) = arg_values.first() { - self.collect_on_exit_uses(body); - } - } - _ => {} - } - } + Ok(()) +} - fn collect_on_exit_uses(&mut self, body: &RSyntaxNode) { - for node in body.descendants() { - if node.kind() == RSyntaxKind::R_IDENTIFIER - && let Some(token) = node.first_token() - { - self.synthetic_used_names - .insert(token.text_trimmed().to_string()); +fn should_lint_definition(facts: &SemanticFacts<'_>, def: &Definition) -> bool { + match def.kind() { + DefinitionKind::Parameter(_) + | DefinitionKind::ForVariable(_) + | DefinitionKind::SuperAssignment(_) + | DefinitionKind::Import { .. } => return false, + DefinitionKind::Assignment(node) => { + if assignment_rhs_is_function_def(node) { + return false; } - } - } - - fn collect_diagnostics(&self, scopes: &[ScopeId]) -> Vec { - let mut out = Vec::new(); - let top_level = ScopeId::from(0); - for &scope_id in scopes { - for (def_id, def) in self.index.definitions(scope_id).iter() { - if !self.should_lint_definition(def) { - continue; - } - if self.is_definition_used(scope_id, def_id, def) { - continue; - } - if scope_id == top_level && self.is_exported(scope_id, def) { - continue; - } - out.push(self.make_diagnostic(scope_id, def)); + // Replacement-function or subset assignment LHS (`names(x) <-`, + // `x[1] <-`, `x$a <-`): the LHS construct reads `x` so the + // surrounding binding is still considered used. + if assignment_lhs_is_complex(node) { + return false; } } - out - } - - fn is_exported(&self, scope_id: ScopeId, def: &Definition) -> bool { - if self.exports.is_empty() { - return false; - } - let name = self.index.symbols(scope_id).symbol_id(def.symbol()).name(); - self.exports.contains(name) } - /// Workaround for oak not recognising `%<>%` as an assignment. Walk the - /// AST for `x %<>% f(...)` expressions; if no later use of `x` exists - /// in the same scope (or via closure capture), emit a synthetic - /// "unused_object" diagnostic on the LHS identifier. - fn collect_assignment_pipe_diagnostics(&self, expressions: &[RSyntaxNode]) -> Vec { - let mut out = Vec::new(); - for expr in expressions { - for node in expr.descendants() { - if node.kind() != RSyntaxKind::R_BINARY_EXPRESSION { - continue; - } - let Some(bin) = node.clone().cast::() else { - continue; - }; - let Ok(op) = bin.operator() else { continue }; - if op.text_trimmed() != "%<>%" { - continue; - } - let Ok(lhs) = bin.left() else { continue }; - if lhs.syntax().kind() != RSyntaxKind::R_IDENTIFIER { - continue; - } - let Some(ident) = lhs.syntax().clone().cast::() else { - continue; - }; - let name = ident.name_text(); - let lhs_range = lhs.syntax().text_trimmed_range(); - let expr_end = bin.syntax().text_trimmed_range().end(); - - // Find the scope containing this expression. - let (scope_id, _) = self.index.scope_at(lhs_range.start()); - - // Look for any later use of `name` in this scope (after - // `expr_end`). - let symbol = self.index.symbols(scope_id).id(&name); - let later_use = symbol.is_some_and(|sym| { - self.index - .uses(scope_id) - .iter() - .any(|(_, u)| u.symbol() == sym && u.range().start() >= expr_end) - }); - // Also check escape via closures. - let closure_use = symbol - .is_some_and(|sym| self.closure_escaped_symbols.contains(&(scope_id, sym))); - // Top-level exported names are used by external callers. - let exported = scope_id == ScopeId::from(0) && self.exports.contains(&name); - - if !later_use && !closure_use && !exported { - out.push(Diagnostic::new( - ViolationData::new( - "unused_object".to_string(), - format!("Object `{name}` is defined but never used."), - None, - ), - lhs_range, - Fix::empty(), - )); - } - } - } - out + // `=` inside a formula RHS is named-arg syntax, not assignment. + if facts.is_in_formula(def.range()) { + return false; } - fn should_lint_definition(&self, def: &Definition) -> bool { - match def.kind() { - DefinitionKind::Parameter(_) - | DefinitionKind::ForVariable(_) - | DefinitionKind::SuperAssignment(_) - | DefinitionKind::Import { .. } => return false, - DefinitionKind::Assignment(node) => { - // 4.10 RHS is a function literal: never lint. - if assignment_rhs_is_function_def(node) { - return false; - } - // 4.1 Replacement-function or subset assignment LHS - // (e.g. `names(x) <-`, `x[1] <-`, `x$a <-`): skip — the LHS - // construct reads x and the test contract is that the - // surrounding `x <- list()` is still considered used. - if assignment_lhs_is_complex(node) { - return false; - } - } - } - - // 4.9 Formula `=` is not assignment. - if self.in_any_range(def.range(), &self.formula_ranges) { - return false; - } + true +} - true +fn is_exported( + semantic: &SemanticIndex, + exports: &std::collections::HashSet, + scope_id: ScopeId, + def: &Definition, +) -> bool { + if exports.is_empty() { + return false; } + let name = semantic.symbols(scope_id).symbol_id(def.symbol()).name(); + exports.contains(name) +} - fn is_definition_used( - &self, - scope_id: ScopeId, - def_id: DefinitionId, - def: &Definition, - ) -> bool { - let symbol_name = self.index.symbols(scope_id).symbol_id(def.symbol()).name(); - - // AST-derived synthetic uses (string interp, do.call, ..cols, on.exit) - if self.synthetic_used_names.contains(symbol_name) { - return true; - } - - if self.is_reached_by_use_in_scope(scope_id, def_id, def.symbol()) { - return true; - } - - // 4.5 Closure escape + call-site analysis. - if self.is_used_via_closure(scope_id, def_id, def.symbol()) { - return true; - } - - // 4.11 `local({…})` shortcut. - if self.is_used_inside_local_body(scope_id, def) { - return true; - } - - false - } +fn make_diagnostic(semantic: &SemanticIndex, scope_id: ScopeId, def: &Definition) -> Diagnostic { + let name = semantic + .symbols(scope_id) + .symbol_id(def.symbol()) + .name() + .to_string(); + let range = lhs_range_for_definition(def).unwrap_or_else(|| def.range()); + Diagnostic::new( + ViolationData::new( + "unused_object".to_string(), + format!("Object `{name}` is defined but never used."), + None, + ), + range, + Fix::empty(), + ) +} - fn is_reached_by_use_in_scope( - &self, - scope_id: ScopeId, - def_id: DefinitionId, - symbol: SymbolId, - ) -> bool { - let use_def = self.index.use_def_map(scope_id); - let uses = self.index.uses(scope_id); - for (use_id, u) in uses.iter() { - if u.symbol() != symbol { - continue; - } - if self.in_any_range(u.range(), &self.nse_ranges) { +/// Workaround for oak not recognising `%<>%` as an assignment. Walk the +/// AST for `x %<>% f(...)` expressions; if no later use of `x` exists in +/// the same scope (or via closure capture), emit a synthetic +/// `unused_object` diagnostic on the LHS identifier. +fn collect_assignment_pipe_diagnostics( + expressions: &[RSyntaxNode], + semantic: &SemanticIndex, + facts: &SemanticFacts<'_>, + exports: &std::collections::HashSet, +) -> Vec { + let mut out = Vec::new(); + for expr in expressions { + for node in expr.descendants() { + if node.kind() != RSyntaxKind::R_BINARY_EXPRESSION { continue; } - let bindings = use_def.bindings_at_use(use_id); - if bindings.contains_definition(def_id) { - return true; - } - } - false - } - - fn is_used_via_closure( - &self, - scope_id: ScopeId, - def_id: DefinitionId, - symbol: SymbolId, - ) -> bool { - if self.closure_escaped_symbols.contains(&(scope_id, symbol)) { - return true; - } - self.closure_used_defs.contains(&(scope_id, def_id)) - } - - /// For each function scope F that's a child of P, classify F's name uses - /// in P into call sites vs. escapes. If F escapes, mark its free vars - /// in P as escaped (all defs of those names get a free pass). If F has - /// only call-site uses, find the latest reaching definition of each - /// free variable at each call site (in source order) and mark those. - fn precompute_closure_uses(&mut self, scopes: &[ScopeId]) { - // Map child Function scope → its bound name in the parent scope (if - // any) and the parent ScopeId. - let mut parent_of: HashMap = HashMap::new(); - for &scope in scopes { - for child in self.index.child_scopes(scope) { - parent_of.insert(child, scope); - } - } - - for &child in scopes { - let Some(&parent) = parent_of.get(&child) else { + let Some(bin) = node.clone().cast::() else { continue; }; - self.classify_closure(parent, child); - } - } - - fn classify_closure(&mut self, parent: ScopeId, child: ScopeId) { - // Find the binding name of `child` in `parent`. Heuristic: an - // assignment in `parent` whose syntax range exactly contains - // `child.range()` and whose RHS is a function definition. If we - // can't find one, treat the closure as anonymous → escape. - let child_range = self.index.scope(child).range(); - - let mut binding_name: Option = None; - for (_, def) in self.index.definitions(parent).iter() { - if let DefinitionKind::Assignment(node) = def.kind() - && node.text_trimmed_range().contains_range(child_range) - && assignment_rhs_is_function_def(node) - { - let name = self - .index - .symbols(parent) - .symbol_id(def.symbol()) - .name() - .to_string(); - binding_name = Some(name); - break; - } - } - - let Some(name) = binding_name else { - // Anonymous closure: any free variable's enclosing-scope defs - // must be conservatively considered used. - self.escape_free_vars(parent, child); - return; - }; - - // Find this name in the parent's symbol table. - let Some(parent_symbol) = self.index.symbols(parent).id(&name) else { - self.escape_free_vars(parent, child); - return; - }; - - // Collect call-site offsets and detect escapes by walking parent - // uses of `parent_symbol`. - let mut call_offsets: Vec = Vec::new(); - let mut escaped = false; - for (_, u) in self.index.uses(parent).iter() { - if u.symbol() != parent_symbol { + let Ok(op) = bin.operator() else { continue }; + if op.text_trimmed() != "%<>%" { continue; } - if self.use_is_callee(u.range()) { - call_offsets.push(u.range().end()); - } else { - escaped = true; - break; + let Ok(lhs) = bin.left() else { continue }; + if lhs.syntax().kind() != RSyntaxKind::R_IDENTIFIER { + continue; } - } + let Some(ident) = lhs.syntax().clone().cast::() else { + continue; + }; + let name = ident.name_text(); + let lhs_range = lhs.syntax().text_trimmed_range(); + let expr_end = bin.syntax().text_trimmed_range().end(); - if escaped || call_offsets.is_empty() { - // If no calls, the closure is bound but never invoked here. - // Conservative: treat as escaped (could be returned implicitly, - // exported, etc.). This matches the test contract for - // `f <- function() { x <- 1; f2 <- function() x; f2 }` where - // f2 is returned and we want to keep `x <- 1` alive. - self.escape_free_vars(parent, child); - return; - } + let (scope_id, _) = semantic.scope_at(lhs_range.start()); - // For each free variable of `child`: find reaching defs in `parent` - // at each call offset, and mark them used. - let free_vars = self.free_variables(child, parent); - for parent_sym in free_vars { - let parent_defs: Vec<(DefinitionId, TextSize)> = self - .index - .definitions(parent) - .iter() - .filter(|(_, d)| d.symbol() == parent_sym) - .map(|(id, d)| (id, d.range().end())) - .collect(); - for &offset in &call_offsets { - if let Some((def_id, _)) = parent_defs + let symbol = semantic.symbols(scope_id).id(&name); + let later_use = symbol.is_some_and(|sym| { + semantic + .uses(scope_id) .iter() - .filter(|(_, def_end)| *def_end <= offset) - .max_by_key(|(_, def_end)| *def_end) - { - self.closure_used_defs.insert((parent, *def_id)); - } - } - } - } + .any(|(_, u)| u.symbol() == sym && u.range().start() >= expr_end) + }); + let closure_use = symbol.is_some_and(|sym| facts.closure_escaped(scope_id, sym)); + let exported = scope_id == ScopeId::from(0) && exports.contains(&name); - fn escape_free_vars(&mut self, parent: ScopeId, child: ScopeId) { - for parent_sym in self.free_variables(child, parent) { - self.closure_escaped_symbols.insert((parent, parent_sym)); - } - } - - /// Names that the child scope (or any nested descendant of it) - /// references but doesn't bind locally — i.e. free variables that - /// resolve to the parent scope. Returns the corresponding parent - /// `SymbolId`s. - fn free_variables(&self, child: ScopeId, parent: ScopeId) -> Vec { - let mut out = Vec::new(); - let mut seen: HashSet = HashSet::new(); - for descendant in self.descendants(child) { - for (_, u) in self.index.uses(descendant).iter() { - let name = self - .index - .symbols(descendant) - .symbol_id(u.symbol()) - .name() - .to_string(); - if !seen.insert(name.clone()) { - continue; - } - if let Some(parent_sym) = self.index.symbols(parent).id(&name) { - out.push(parent_sym); - } + if !later_use && !closure_use && !exported { + out.push(Diagnostic::new( + ViolationData::new( + "unused_object".to_string(), + format!("Object `{name}` is defined but never used."), + None, + ), + lhs_range, + Fix::empty(), + )); } } - out - } - - /// Is the given identifier-use range the callee of an `R_CALL`? We - /// approximate by walking the AST starting from any descendant whose - /// range matches; cheaper than tracking node IDs. - fn use_is_callee(&self, range: TextRange) -> bool { - // We can't access the syntax root from `Ctx`. Instead, rely on the - // separately-tracked callee ranges populated during AST walk. - self.callee_ranges - .iter() - .any(|r| r.contains_range(range) || *r == range) - } - - fn is_used_inside_local_body(&self, scope_id: ScopeId, def: &Definition) -> bool { - if self.local_body_ranges.is_empty() { - return false; - } - let Some(body_range) = self - .local_body_ranges - .iter() - .find(|r| r.contains_range(def.range())) - .copied() - else { - return false; - }; - for (_, u) in self.index.uses(scope_id).iter() { - if u.symbol() != def.symbol() { - continue; - } - if body_range.contains_range(u.range()) { - return true; - } - } - false - } - - fn descendants(&self, scope_id: ScopeId) -> Vec { - let mut out = vec![scope_id]; - let mut stack = vec![scope_id]; - while let Some(s) = stack.pop() { - for child in self.index.child_scopes(s) { - out.push(child); - stack.push(child); - } - } - out - } - - fn in_any_range(&self, target: TextRange, ranges: &[TextRange]) -> bool { - ranges.iter().any(|r| r.contains_range(target)) - } - - fn make_diagnostic(&self, scope_id: ScopeId, def: &Definition) -> Diagnostic { - let name = self - .index - .symbols(scope_id) - .symbol_id(def.symbol()) - .name() - .to_string(); - let range = lhs_range_for_definition(def).unwrap_or_else(|| def.range()); - Diagnostic::new( - ViolationData::new( - "unused_object".to_string(), - format!("Object `{name}` is defined but never used."), - None, - ), - range, - Fix::empty(), - ) - } -} - -fn is_member_name(node: &RSyntaxNode) -> bool { - let Some(parent) = node.parent() else { - return false; - }; - match parent.kind() { - RSyntaxKind::R_EXTRACT_EXPRESSION => parent - .cast::() - .and_then(|e| e.right().ok()) - .is_some_and(|r| r.syntax() == node), - RSyntaxKind::R_NAMESPACE_EXPRESSION => parent - .cast::() - .and_then(|e| e.right().ok()) - .is_some_and(|r| r.syntax() == node), - _ => false, - } -} - -fn assignment_rhs_is_function_def(node: &RSyntaxNode) -> bool { - // The RHS of a `<-` / `=` / `->` binary expression. We just look for - // any `R_FUNCTION_DEFINITION` direct child — `RBinaryExpression`'s left - // and right slots are themselves `R_FUNCTION_DEFINITION` if applicable. - for child in node.children() { - if child.kind() == RSyntaxKind::R_FUNCTION_DEFINITION { - return true; - } - } - false -} - -fn assignment_lhs_is_complex(node: &RSyntaxNode) -> bool { - // For binary assignment, peek at the LHS and treat anything other than - // a bare identifier as "complex" (e.g. `names(x)`, `x[1]`, `x$a`). - // For `->`/`->>` assignments the LHS is the rightmost side; we treat - // those identically. - let Some(bin) = node.clone().cast::() else { - return false; - }; - let Ok(op) = bin.operator() else { - return false; - }; - let lhs = if op.text_trimmed() == "->" || op.text_trimmed() == "->>" { - bin.right().ok().map(|n| n.syntax().clone()) - } else { - bin.left().ok().map(|n| n.syntax().clone()) - }; - match lhs { - Some(node) => !matches!(node.kind(), RSyntaxKind::R_IDENTIFIER), - None => false, - } -} - -fn lhs_range_for_definition(def: &Definition) -> Option { - let node = match def.kind() { - DefinitionKind::Assignment(n) | DefinitionKind::SuperAssignment(n) => n, - DefinitionKind::Parameter(n) | DefinitionKind::ForVariable(n) => { - return Some(n.text_trimmed_range()); - } - DefinitionKind::Import { .. } => return None, - }; - let bin = node.clone().cast::()?; - let op = bin.operator().ok()?; - let lhs = if op.text_trimmed() == "->" || op.text_trimmed() == "->>" { - bin.right().ok()? - } else { - bin.left().ok()? - }; - let lhs_node = lhs.syntax(); - if lhs_node.kind() == RSyntaxKind::R_IDENTIFIER { - Some(lhs_node.text_trimmed_range()) - } else { - // Fall back to the whole assignment range if the LHS isn't a - // simple identifier. - None - } -} - -fn call_name(call: &RCall) -> Option { - let func = call.function().ok()?; - match func { - AnyRExpression::RIdentifier(ident) => Some(ident.name_text()), - AnyRExpression::RNamespaceExpression(ns) => { - // Match on the right-hand identifier (`pkg::name` ⇒ `name`). - ns.right() - .ok() - .and_then(|r| r.syntax().first_token()) - .map(|t| t.text_trimmed().to_string()) - } - _ => None, - } -} - -fn call_args(call: &RCall) -> Vec<(Option, RSyntaxNode)> { - let Ok(arguments) = call.arguments() else { - return Vec::new(); - }; - let items = arguments.items(); - args_iter(&items) -} - -fn args_iter(list: &RArgumentList) -> Vec<(Option, RSyntaxNode)> { - let mut out = Vec::new(); - for item in list.iter() { - let Ok(arg) = item else { continue }; - let name = argument_name(&arg); - let value = arg.value().map(|v| v.syntax().clone()); - if let Some(value) = value { - out.push((name, value)); - } } out } - -/// Return the LHS identifier name of a binary assignment expression -/// (`x <- …`, `x = …`, `… -> x`, `x <<- …`, `… ->> x`). Returns None for -/// any other binary expression or for assignments whose LHS isn't a bare -/// identifier. -fn assignment_lhs_name(node: &RSyntaxNode) -> Option { - let bin = node.clone().cast::()?; - let op = bin.operator().ok()?; - let op_text = op.text_trimmed(); - let lhs = match op_text { - "<-" | "<<-" | "=" => bin.left().ok()?, - "->" | "->>" => bin.right().ok()?, - _ => return None, - }; - let node = lhs.syntax(); - if node.kind() == RSyntaxKind::R_IDENTIFIER { - let ident = node.clone().cast::()?; - Some(ident.name_text()) - } else { - None - } -} - -fn argument_name(arg: &RArgument) -> Option { - let clause = arg.name_clause()?; - let name = clause.name().ok()?; - match name { - AnyRArgumentName::RIdentifier(ident) => Some(ident.name_text()), - AnyRArgumentName::RDots(_) => Some("...".to_string()), - _ => None, - } -} - -fn string_literal_value(node: &RSyntaxNode) -> Option { - if node.kind() != RSyntaxKind::R_STRING_VALUE { - return None; - } - let token = node.first_token()?; - let text = token.text_trimmed(); - let bytes = text.as_bytes(); - if bytes.len() < 2 { - return None; - } - let first = bytes[0]; - let last = *bytes.last().unwrap(); - if (first == b'"' || first == b'\'') && first == last { - Some(text[1..text.len() - 1].to_string()) - } else { - None - } -} diff --git a/crates/jarl-semantic/Cargo.toml b/crates/jarl-semantic/Cargo.toml new file mode 100644 index 000000000..2733eefeb --- /dev/null +++ b/crates/jarl-semantic/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "jarl-semantic" +description = "Per-file semantic facts for jarl R linter rules" +version = "0.5.0" +authors.workspace = true +edition.workspace = true +license-file.workspace = true +repository.workspace = true +readme.workspace = true + +[lib] +name = "jarl_semantic" +path = "src/lib.rs" + +[dependencies] +air_r_parser.workspace = true +air_r_syntax.workspace = true +biome_rowan.workspace = true + +oak_core.workspace = true +oak_index.workspace = true +oak_index_vec.workspace = true diff --git a/crates/jarl-semantic/src/lib.rs b/crates/jarl-semantic/src/lib.rs new file mode 100644 index 000000000..565887e4a --- /dev/null +++ b/crates/jarl-semantic/src/lib.rs @@ -0,0 +1,684 @@ +//! Per-file semantic facts for jarl lint rules. +//! +//! `SemanticFacts` is computed once over a parsed file and exposes the +//! information lint rules need to answer "is this definition used?", without +//! every rule reimplementing the AST passes (NSE detection, string +//! interpolation, closure escape analysis, ...) on top of oak's +//! `SemanticIndex`. +//! +//! Mirrors ruff's `Binding::is_unused()` style: rules ask +//! `facts.is_definition_used(scope, def_id, def)` rather than walking the +//! semantic index themselves. + +use std::collections::{HashMap, HashSet}; + +use air_r_parser::RParserOptions; +use air_r_syntax::{ + AnyRArgumentName, AnyRExpression, RArgument, RArgumentList, RBinaryExpression, RCall, + RExtractExpression, RNamespaceExpression, RSyntaxKind, RSyntaxNode, +}; +use biome_rowan::{AstNode, AstSeparatedList, SyntaxNodeCast, TextRange, TextSize}; +use oak_core::syntax_ext::RIdentifierExt; +use oak_index::DefinitionId; +use oak_index::semantic_index::{Definition, DefinitionKind, ScopeId, SemanticIndex, SymbolId}; + +/// Per-file semantic facts derived from oak's [`SemanticIndex`] plus AST +/// passes over the syntax tree. Computed once per file; consumed by lints. +pub struct SemanticFacts<'a> { + index: &'a SemanticIndex, + /// Names that have a synthetic use from AST passes (string interpolation, + /// `do.call("f", …)`, `..cols`, `on.exit` bodies, loop assignment LHSes, + /// short-circuit assignment LHSes). A definition whose symbol name is in + /// this set is treated as used. + synthetic_used_names: HashSet, + /// Identifier `Use` ranges that should be ignored because they sit inside + /// an NSE call argument (`quote(x)`, `substitute(…)`, `bquote(…)`, …). + nse_ranges: Vec, + /// Bodies of `local({...})` calls. + local_body_ranges: Vec, + /// Ranges of formula RHSes (`~ rhs`). + formula_ranges: Vec, + /// Definitions reachable through call-site analysis of nested closures. + closure_used_defs: HashSet<(ScopeId, DefinitionId)>, + /// Symbols whose nested closure escapes (returned/anonymous/passed as + /// argument); all enclosing-scope defs of that symbol are conservatively + /// considered used. + closure_escaped_symbols: HashSet<(ScopeId, SymbolId)>, + /// Identifier ranges that appear as the callee of an `R_CALL`. + callee_ranges: Vec, +} + +impl<'a> SemanticFacts<'a> { + /// Build the facts table. Runs both the AST pass (collecting synthetic + /// uses, NSE ranges, formula ranges, local body ranges, callee ranges) + /// and the closure call-site analysis. + pub fn build(expressions: &[RSyntaxNode], index: &'a SemanticIndex) -> Self { + let mut this = Self { + index, + synthetic_used_names: HashSet::new(), + nse_ranges: Vec::new(), + local_body_ranges: Vec::new(), + formula_ranges: Vec::new(), + closure_used_defs: HashSet::new(), + closure_escaped_symbols: HashSet::new(), + callee_ranges: Vec::new(), + }; + this.collect_ast_passes(expressions); + let scopes = this.scope_ids(); + this.precompute_closure_uses(&scopes); + this + } + + pub fn index(&self) -> &SemanticIndex { + self.index + } + + /// Walk all scopes (root + descendants) in arbitrary order. + pub fn scope_ids(&self) -> Vec { + let mut ids = Vec::new(); + let mut stack = vec![ScopeId::from(0)]; + while let Some(s) = stack.pop() { + ids.push(s); + stack.extend(self.index.child_scopes(s)); + } + ids + } + + // ── High-level queries ──────────────────────────────────────────── + + /// True if any of the supported "is used" conditions hold for this + /// definition: synthetic AST-derived use, in-scope reaching use, + /// closure-escape, or `local({…})` body shortcut. + pub fn is_definition_used( + &self, + scope_id: ScopeId, + def_id: DefinitionId, + def: &Definition, + ) -> bool { + let symbol_name = self.index.symbols(scope_id).symbol_id(def.symbol()).name(); + if self.synthetic_used_names.contains(symbol_name) { + return true; + } + if self.is_reached_by_use_in_scope(scope_id, def_id, def.symbol()) { + return true; + } + if self.is_used_via_closure(scope_id, def_id, def.symbol()) { + return true; + } + if self.is_used_inside_local_body(scope_id, def) { + return true; + } + false + } + + // ── Low-level predicates (compose for new rules) ────────────────── + + pub fn is_in_formula(&self, range: TextRange) -> bool { + in_any_range(range, &self.formula_ranges) + } + + pub fn is_in_nse(&self, range: TextRange) -> bool { + in_any_range(range, &self.nse_ranges) + } + + pub fn has_synthetic_use(&self, name: &str) -> bool { + self.synthetic_used_names.contains(name) + } + + pub fn closure_escaped(&self, scope: ScopeId, symbol: SymbolId) -> bool { + self.closure_escaped_symbols.contains(&(scope, symbol)) + } + + pub fn is_callee(&self, range: TextRange) -> bool { + self.callee_ranges + .iter() + .any(|r| r.contains_range(range) || *r == range) + } + + // ── Internal: AST pass ──────────────────────────────────────────── + + fn collect_ast_passes(&mut self, expressions: &[RSyntaxNode]) { + for expr in expressions { + for node in expr.descendants() { + self.visit_node(&node); + } + } + } + + fn visit_node(&mut self, node: &RSyntaxNode) { + match node.kind() { + RSyntaxKind::R_STRING_VALUE => self.collect_string_interpolation(node), + RSyntaxKind::R_CALL => { + if let Some(call) = node.clone().cast::() { + self.visit_call(&call); + } + } + RSyntaxKind::R_DOT_DOT_I => self.collect_dotdot_identifier(node), + RSyntaxKind::R_IDENTIFIER => self.collect_dotdot_identifier(node), + RSyntaxKind::R_BINARY_EXPRESSION => { + if let Some(bin) = node.clone().cast::() { + self.visit_binary(&bin); + } + } + RSyntaxKind::R_WHILE_STATEMENT + | RSyntaxKind::R_FOR_STATEMENT + | RSyntaxKind::R_REPEAT_STATEMENT => { + self.collect_loop_assignment_names(node); + } + _ => {} + } + } + + /// Workaround for oak not retroactively connecting loop-body defs to + /// loop-condition uses. Coarse but matches the test contract. + fn collect_loop_assignment_names(&mut self, loop_node: &RSyntaxNode) { + for descendant in loop_node.descendants() { + if descendant.kind() != RSyntaxKind::R_BINARY_EXPRESSION { + continue; + } + if let Some(name) = assignment_lhs_name(&descendant) { + self.synthetic_used_names.insert(name); + } + } + } + + fn collect_string_interpolation(&mut self, node: &RSyntaxNode) { + let Some(token) = node.first_token() else { + return; + }; + let text = token.text_trimmed(); + let bytes = text.as_bytes(); + let mut i = 0; + while i < bytes.len() { + // glue's `{{` / `}}` are escaped literals, not interpolations. + if bytes[i] == b'{' && bytes.get(i + 1) == Some(&b'{') { + i += 2; + continue; + } + if bytes[i] == b'}' && bytes.get(i + 1) == Some(&b'}') { + i += 2; + continue; + } + if bytes[i] == b'{' { + let start = i + 1; + let mut depth = 1usize; + let mut end = start; + while end < bytes.len() && depth > 0 { + match bytes[end] { + b'{' => depth += 1, + b'}' => depth -= 1, + _ => {} + } + if depth > 0 { + end += 1; + } + } + if depth == 0 && end > start { + self.collect_identifiers_in_interpolation(&text[start..end]); + } + i = end + 1; + } else { + i += 1; + } + } + } + + /// Parse a glue-style `{...}` interpolation as R code and collect every + /// identifier reference. Skips the field side of `x$a` / `x@a` and the + /// namespace side of `pkg::name` — those name members, not bindings. + fn collect_identifiers_in_interpolation(&mut self, src: &str) { + let parsed = air_r_parser::parse(src, RParserOptions::default()); + if parsed.has_error() { + return; + } + for node in parsed.syntax().descendants() { + if node.kind() != RSyntaxKind::R_IDENTIFIER { + continue; + } + if is_member_name(&node) { + continue; + } + if let Some(token) = node.first_token() { + self.synthetic_used_names + .insert(token.text_trimmed().to_string()); + } + } + } + + fn collect_dotdot_identifier(&mut self, node: &RSyntaxNode) { + let Some(token) = node.first_token() else { + return; + }; + let text = token.text_trimmed(); + if let Some(stripped) = text.strip_prefix("..") + && !stripped.is_empty() + && stripped + .chars() + .next() + .is_some_and(|c| c.is_alphabetic() || c == '_' || c == '.') + { + self.synthetic_used_names.insert(stripped.to_string()); + } + } + + fn visit_binary(&mut self, bin: &RBinaryExpression) { + let Ok(op) = bin.operator() else { + return; + }; + let op_text = op.text_trimmed(); + // Formulas are `R_BINARY_EXPRESSION` with a `~` operator. + if op_text == "~" { + self.formula_ranges.push(bin.syntax().text_trimmed_range()); + self.nse_ranges.push(bin.syntax().text_trimmed_range()); + return; + } + + // Short-circuit operators: `cond || (x <- 2)` may skip the + // assignment entirely, so prior defs of `x` should remain alive. + // Oak walks linearly and shadows them. Workaround: any LHS + // assigned inside a `||`/`&&` is considered synthetically used. + if op_text == "||" || op_text == "&&" || op_text == "|" || op_text == "&" { + for descendant in bin.syntax().descendants() { + if descendant.kind() == RSyntaxKind::R_BINARY_EXPRESSION + && let Some(name) = assignment_lhs_name(&descendant) + { + self.synthetic_used_names.insert(name); + } + } + } + } + + fn visit_call(&mut self, call: &RCall) { + if let Ok(func) = call.function() { + self.callee_ranges.push(func.syntax().text_trimmed_range()); + } + + let Some(name) = call_name(call) else { + return; + }; + + let arg_values: Vec<(Option, RSyntaxNode)> = call_args(call); + + match name.as_str() { + "quote" | "substitute" | "bquote" | "enquote" | "expression" | "Quote" => { + for (_, value) in &arg_values { + self.nse_ranges.push(value.text_trimmed_range()); + } + } + "do.call" | "match.fun" | "Recall" | "getFunction" => { + if let Some((_, first)) = arg_values.first() + && let Some(s) = string_literal_value(first) + { + self.synthetic_used_names.insert(s); + } + } + "local" => { + if let Some((_, body)) = arg_values.first() { + self.local_body_ranges.push(body.text_trimmed_range()); + } + } + "on.exit" => { + if let Some((_, body)) = arg_values.first() { + self.collect_on_exit_uses(body); + } + } + _ => {} + } + } + + fn collect_on_exit_uses(&mut self, body: &RSyntaxNode) { + for node in body.descendants() { + if node.kind() == RSyntaxKind::R_IDENTIFIER + && let Some(token) = node.first_token() + { + self.synthetic_used_names + .insert(token.text_trimmed().to_string()); + } + } + } + + // ── Internal: reach / closure analysis ──────────────────────────── + + fn is_reached_by_use_in_scope( + &self, + scope_id: ScopeId, + def_id: DefinitionId, + symbol: SymbolId, + ) -> bool { + let use_def = self.index.use_def_map(scope_id); + let uses = self.index.uses(scope_id); + for (use_id, u) in uses.iter() { + if u.symbol() != symbol { + continue; + } + if in_any_range(u.range(), &self.nse_ranges) { + continue; + } + let bindings = use_def.bindings_at_use(use_id); + if bindings.contains_definition(def_id) { + return true; + } + } + false + } + + fn is_used_via_closure( + &self, + scope_id: ScopeId, + def_id: DefinitionId, + symbol: SymbolId, + ) -> bool { + if self.closure_escaped_symbols.contains(&(scope_id, symbol)) { + return true; + } + self.closure_used_defs.contains(&(scope_id, def_id)) + } + + fn precompute_closure_uses(&mut self, scopes: &[ScopeId]) { + let mut parent_of: HashMap = HashMap::new(); + for &scope in scopes { + for child in self.index.child_scopes(scope) { + parent_of.insert(child, scope); + } + } + for &child in scopes { + let Some(&parent) = parent_of.get(&child) else { + continue; + }; + self.classify_closure(parent, child); + } + } + + fn classify_closure(&mut self, parent: ScopeId, child: ScopeId) { + let child_range = self.index.scope(child).range(); + + let mut binding_name: Option = None; + for (_, def) in self.index.definitions(parent).iter() { + if let DefinitionKind::Assignment(node) = def.kind() + && node.text_trimmed_range().contains_range(child_range) + && assignment_rhs_is_function_def(node) + { + let name = self + .index + .symbols(parent) + .symbol_id(def.symbol()) + .name() + .to_string(); + binding_name = Some(name); + break; + } + } + + let Some(name) = binding_name else { + self.escape_free_vars(parent, child); + return; + }; + + let Some(parent_symbol) = self.index.symbols(parent).id(&name) else { + self.escape_free_vars(parent, child); + return; + }; + + let mut call_offsets: Vec = Vec::new(); + let mut escaped = false; + for (_, u) in self.index.uses(parent).iter() { + if u.symbol() != parent_symbol { + continue; + } + if self.is_callee(u.range()) { + call_offsets.push(u.range().end()); + } else { + escaped = true; + break; + } + } + + if escaped || call_offsets.is_empty() { + self.escape_free_vars(parent, child); + return; + } + + let free_vars = self.free_variables(child, parent); + for parent_sym in free_vars { + let parent_defs: Vec<(DefinitionId, TextSize)> = self + .index + .definitions(parent) + .iter() + .filter(|(_, d)| d.symbol() == parent_sym) + .map(|(id, d)| (id, d.range().end())) + .collect(); + for &offset in &call_offsets { + if let Some((def_id, _)) = parent_defs + .iter() + .filter(|(_, def_end)| *def_end <= offset) + .max_by_key(|(_, def_end)| *def_end) + { + self.closure_used_defs.insert((parent, *def_id)); + } + } + } + } + + fn escape_free_vars(&mut self, parent: ScopeId, child: ScopeId) { + for parent_sym in self.free_variables(child, parent) { + self.closure_escaped_symbols.insert((parent, parent_sym)); + } + } + + fn free_variables(&self, child: ScopeId, parent: ScopeId) -> Vec { + let mut out = Vec::new(); + let mut seen: HashSet = HashSet::new(); + for descendant in self.scope_descendants(child) { + for (_, u) in self.index.uses(descendant).iter() { + let name = self + .index + .symbols(descendant) + .symbol_id(u.symbol()) + .name() + .to_string(); + if !seen.insert(name.clone()) { + continue; + } + if let Some(parent_sym) = self.index.symbols(parent).id(&name) { + out.push(parent_sym); + } + } + } + out + } + + fn is_used_inside_local_body(&self, scope_id: ScopeId, def: &Definition) -> bool { + if self.local_body_ranges.is_empty() { + return false; + } + let Some(body_range) = self + .local_body_ranges + .iter() + .find(|r| r.contains_range(def.range())) + .copied() + else { + return false; + }; + for (_, u) in self.index.uses(scope_id).iter() { + if u.symbol() != def.symbol() { + continue; + } + if body_range.contains_range(u.range()) { + return true; + } + } + false + } + + fn scope_descendants(&self, scope_id: ScopeId) -> Vec { + let mut out = vec![scope_id]; + let mut stack = vec![scope_id]; + while let Some(s) = stack.pop() { + for child in self.index.child_scopes(s) { + out.push(child); + stack.push(child); + } + } + out + } +} + +// ── Free helpers (also used by rule policy) ────────────────────────────── + +fn in_any_range(target: TextRange, ranges: &[TextRange]) -> bool { + ranges.iter().any(|r| r.contains_range(target)) +} + +fn is_member_name(node: &RSyntaxNode) -> bool { + let Some(parent) = node.parent() else { + return false; + }; + match parent.kind() { + RSyntaxKind::R_EXTRACT_EXPRESSION => parent + .cast::() + .and_then(|e| e.right().ok()) + .is_some_and(|r| r.syntax() == node), + RSyntaxKind::R_NAMESPACE_EXPRESSION => parent + .cast::() + .and_then(|e| e.right().ok()) + .is_some_and(|r| r.syntax() == node), + _ => false, + } +} + +/// True if the RHS of a binary assignment is a function definition. +pub fn assignment_rhs_is_function_def(node: &RSyntaxNode) -> bool { + for child in node.children() { + if child.kind() == RSyntaxKind::R_FUNCTION_DEFINITION { + return true; + } + } + false +} + +/// True if the LHS of a binary assignment is anything other than a bare +/// identifier (e.g. `names(x)`, `x[1]`, `x$a`). +pub fn assignment_lhs_is_complex(node: &RSyntaxNode) -> bool { + let Some(bin) = node.clone().cast::() else { + return false; + }; + let Ok(op) = bin.operator() else { + return false; + }; + let lhs = if op.text_trimmed() == "->" || op.text_trimmed() == "->>" { + bin.right().ok().map(|n| n.syntax().clone()) + } else { + bin.left().ok().map(|n| n.syntax().clone()) + }; + match lhs { + Some(node) => !matches!(node.kind(), RSyntaxKind::R_IDENTIFIER), + None => false, + } +} + +/// The text range of the bare-identifier LHS of an assignment, if any. +pub fn lhs_range_for_definition(def: &Definition) -> Option { + let node = match def.kind() { + DefinitionKind::Assignment(n) | DefinitionKind::SuperAssignment(n) => n, + DefinitionKind::Parameter(n) | DefinitionKind::ForVariable(n) => { + return Some(n.text_trimmed_range()); + } + DefinitionKind::Import { .. } => return None, + }; + let bin = node.clone().cast::()?; + let op = bin.operator().ok()?; + let lhs = if op.text_trimmed() == "->" || op.text_trimmed() == "->>" { + bin.right().ok()? + } else { + bin.left().ok()? + }; + let lhs_node = lhs.syntax(); + if lhs_node.kind() == RSyntaxKind::R_IDENTIFIER { + Some(lhs_node.text_trimmed_range()) + } else { + None + } +} + +fn call_name(call: &RCall) -> Option { + let func = call.function().ok()?; + match func { + AnyRExpression::RIdentifier(ident) => Some(ident.name_text()), + AnyRExpression::RNamespaceExpression(ns) => ns + .right() + .ok() + .and_then(|r| r.syntax().first_token()) + .map(|t| t.text_trimmed().to_string()), + _ => None, + } +} + +fn call_args(call: &RCall) -> Vec<(Option, RSyntaxNode)> { + let Ok(arguments) = call.arguments() else { + return Vec::new(); + }; + let items = arguments.items(); + args_iter(&items) +} + +fn args_iter(list: &RArgumentList) -> Vec<(Option, RSyntaxNode)> { + let mut out = Vec::new(); + for item in list.iter() { + let Ok(arg) = item else { continue }; + let name = argument_name(&arg); + let value = arg.value().map(|v| v.syntax().clone()); + if let Some(value) = value { + out.push((name, value)); + } + } + out +} + +/// LHS identifier name of `x <- …` / `x = …` / `… -> x` / `x <<- …` / +/// `… ->> x`. None for any other binary expression. +pub fn assignment_lhs_name(node: &RSyntaxNode) -> Option { + let bin = node.clone().cast::()?; + let op = bin.operator().ok()?; + let op_text = op.text_trimmed(); + let lhs = match op_text { + "<-" | "<<-" | "=" => bin.left().ok()?, + "->" | "->>" => bin.right().ok()?, + _ => return None, + }; + let node = lhs.syntax(); + if node.kind() == RSyntaxKind::R_IDENTIFIER { + let ident = node.clone().cast::()?; + Some(ident.name_text()) + } else { + None + } +} + +fn argument_name(arg: &RArgument) -> Option { + let clause = arg.name_clause()?; + let name = clause.name().ok()?; + match name { + AnyRArgumentName::RIdentifier(ident) => Some(ident.name_text()), + AnyRArgumentName::RDots(_) => Some("...".to_string()), + _ => None, + } +} + +fn string_literal_value(node: &RSyntaxNode) -> Option { + if node.kind() != RSyntaxKind::R_STRING_VALUE { + return None; + } + let token = node.first_token()?; + let text = token.text_trimmed(); + let bytes = text.as_bytes(); + if bytes.len() < 2 { + return None; + } + let first = bytes[0]; + let last = *bytes.last().unwrap(); + if (first == b'"' || first == b'\'') && first == last { + Some(text[1..text.len() - 1].to_string()) + } else { + None + } +} From 5643b8bd7e7438ea8b0190636512b1234c12f1e6 Mon Sep 17 00:00:00 2001 From: etiennebacher Date: Mon, 11 May 2026 21:49:32 +0100 Subject: [PATCH 04/39] rename facts to info --- crates/jarl-core/Cargo.toml | 2 +- .../lints/base/unused_object/unused_object.rs | 20 +++++++++---------- crates/jarl-semantic/Cargo.toml | 2 +- crates/jarl-semantic/src/lib.rs | 14 ++++++------- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/crates/jarl-core/Cargo.toml b/crates/jarl-core/Cargo.toml index 42c8e8915..1fd6492a7 100644 --- a/crates/jarl-core/Cargo.toml +++ b/crates/jarl-core/Cargo.toml @@ -29,7 +29,7 @@ oak_index.workspace = true oak_index_vec.workspace = true url.workspace = true -# Per-file semantic facts (used by DFG-based lints) +# Per-file semantic info (used by semantic-aware lints) jarl-semantic.workspace = true # Serialization and data structures diff --git a/crates/jarl-core/src/lints/base/unused_object/unused_object.rs b/crates/jarl-core/src/lints/base/unused_object/unused_object.rs index 03fc186a4..9b01fc3e2 100644 --- a/crates/jarl-core/src/lints/base/unused_object/unused_object.rs +++ b/crates/jarl-core/src/lints/base/unused_object/unused_object.rs @@ -4,7 +4,7 @@ use oak_core::syntax_ext::RIdentifierExt; use oak_index::semantic_index::{Definition, DefinitionKind, ScopeId, SemanticIndex}; use jarl_semantic::{ - SemanticFacts, assignment_lhs_is_complex, assignment_rhs_is_function_def, + SemanticInfo, assignment_lhs_is_complex, assignment_rhs_is_function_def, lhs_range_for_definition, }; @@ -36,17 +36,17 @@ pub fn unused_object( semantic: &SemanticIndex, checker: &mut Checker, ) -> anyhow::Result<()> { - let facts = SemanticFacts::build(expressions, semantic); + let info = SemanticInfo::build(expressions, semantic); let exports = &checker.namespace_exports; let mut diagnostics = Vec::new(); let top_level = ScopeId::from(0); - for &scope_id in &facts.scope_ids() { + for &scope_id in &info.scope_ids() { for (def_id, def) in semantic.definitions(scope_id).iter() { - if !should_lint_definition(&facts, def) { + if !should_lint_definition(&info, def) { continue; } - if facts.is_definition_used(scope_id, def_id, def) { + if info.is_definition_used(scope_id, def_id, def) { continue; } if scope_id == top_level && is_exported(semantic, exports, scope_id, def) { @@ -58,7 +58,7 @@ pub fn unused_object( diagnostics.extend(collect_assignment_pipe_diagnostics( expressions, semantic, - &facts, + &info, exports, )); @@ -69,7 +69,7 @@ pub fn unused_object( Ok(()) } -fn should_lint_definition(facts: &SemanticFacts<'_>, def: &Definition) -> bool { +fn should_lint_definition(info: &SemanticInfo<'_>, def: &Definition) -> bool { match def.kind() { DefinitionKind::Parameter(_) | DefinitionKind::ForVariable(_) @@ -89,7 +89,7 @@ fn should_lint_definition(facts: &SemanticFacts<'_>, def: &Definition) -> bool { } // `=` inside a formula RHS is named-arg syntax, not assignment. - if facts.is_in_formula(def.range()) { + if info.is_in_formula(def.range()) { return false; } @@ -134,7 +134,7 @@ fn make_diagnostic(semantic: &SemanticIndex, scope_id: ScopeId, def: &Definition fn collect_assignment_pipe_diagnostics( expressions: &[RSyntaxNode], semantic: &SemanticIndex, - facts: &SemanticFacts<'_>, + info: &SemanticInfo<'_>, exports: &std::collections::HashSet, ) -> Vec { let mut out = Vec::new(); @@ -170,7 +170,7 @@ fn collect_assignment_pipe_diagnostics( .iter() .any(|(_, u)| u.symbol() == sym && u.range().start() >= expr_end) }); - let closure_use = symbol.is_some_and(|sym| facts.closure_escaped(scope_id, sym)); + let closure_use = symbol.is_some_and(|sym| info.closure_escaped(scope_id, sym)); let exported = scope_id == ScopeId::from(0) && exports.contains(&name); if !later_use && !closure_use && !exported { diff --git a/crates/jarl-semantic/Cargo.toml b/crates/jarl-semantic/Cargo.toml index 2733eefeb..f288a3046 100644 --- a/crates/jarl-semantic/Cargo.toml +++ b/crates/jarl-semantic/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jarl-semantic" -description = "Per-file semantic facts for jarl R linter rules" +description = "Per-file semantic info for jarl R linter rules" version = "0.5.0" authors.workspace = true edition.workspace = true diff --git a/crates/jarl-semantic/src/lib.rs b/crates/jarl-semantic/src/lib.rs index 565887e4a..5d7cdf4b6 100644 --- a/crates/jarl-semantic/src/lib.rs +++ b/crates/jarl-semantic/src/lib.rs @@ -1,13 +1,13 @@ -//! Per-file semantic facts for jarl lint rules. +//! Per-file semantic info for jarl lint rules. //! -//! `SemanticFacts` is computed once over a parsed file and exposes the +//! `SemanticInfo` is computed once over a parsed file and exposes the //! information lint rules need to answer "is this definition used?", without //! every rule reimplementing the AST passes (NSE detection, string //! interpolation, closure escape analysis, ...) on top of oak's //! `SemanticIndex`. //! //! Mirrors ruff's `Binding::is_unused()` style: rules ask -//! `facts.is_definition_used(scope, def_id, def)` rather than walking the +//! `info.is_definition_used(scope, def_id, def)` rather than walking the //! semantic index themselves. use std::collections::{HashMap, HashSet}; @@ -22,9 +22,9 @@ use oak_core::syntax_ext::RIdentifierExt; use oak_index::DefinitionId; use oak_index::semantic_index::{Definition, DefinitionKind, ScopeId, SemanticIndex, SymbolId}; -/// Per-file semantic facts derived from oak's [`SemanticIndex`] plus AST +/// Per-file semantic info derived from oak's [`SemanticIndex`] plus AST /// passes over the syntax tree. Computed once per file; consumed by lints. -pub struct SemanticFacts<'a> { +pub struct SemanticInfo<'a> { index: &'a SemanticIndex, /// Names that have a synthetic use from AST passes (string interpolation, /// `do.call("f", …)`, `..cols`, `on.exit` bodies, loop assignment LHSes, @@ -48,8 +48,8 @@ pub struct SemanticFacts<'a> { callee_ranges: Vec, } -impl<'a> SemanticFacts<'a> { - /// Build the facts table. Runs both the AST pass (collecting synthetic +impl<'a> SemanticInfo<'a> { + /// Build the info table. Runs both the AST pass (collecting synthetic /// uses, NSE ranges, formula ranges, local body ranges, callee ranges) /// and the closure call-site analysis. pub fn build(expressions: &[RSyntaxNode], index: &'a SemanticIndex) -> Self { From 67402ca992df875663fc8058caa34539e688ad55 Mon Sep 17 00:00:00 2001 From: etiennebacher Date: Mon, 11 May 2026 23:42:57 +0100 Subject: [PATCH 05/39] fix tests --- crates/jarl-lsp/src/server.rs | 4 ++-- crates/jarl/tests/integration/add_jarl_ignore.rs | 12 ++++++------ crates/jarl/tests/integration/comments.rs | 2 ++ crates/jarl/tests/integration/statistics.rs | 4 ++-- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/crates/jarl-lsp/src/server.rs b/crates/jarl-lsp/src/server.rs index d1bc5c1c6..3b016949c 100644 --- a/crates/jarl-lsp/src/server.rs +++ b/crates/jarl-lsp/src/server.rs @@ -1377,8 +1377,8 @@ x |> fn test_suppression_no_duplicate_rule() { let result = apply_jarl_ignore_at_cursor( r#" -# jarl-ignore assignment: already suppressed -x = 1 +# jarl-ignore any_is_na: already suppressed +any(is.na(x)) "#, ); diff --git a/crates/jarl/tests/integration/add_jarl_ignore.rs b/crates/jarl/tests/integration/add_jarl_ignore.rs index 390f03a93..55ff7a89c 100644 --- a/crates/jarl/tests/integration/add_jarl_ignore.rs +++ b/crates/jarl/tests/integration/add_jarl_ignore.rs @@ -221,7 +221,7 @@ fn test_add_jarl_ignore_multiple_files() -> anyhow::Result<()> { #[test] fn test_add_jarl_ignore_no_violations() -> anyhow::Result<()> { - let case = CliTest::with_file("test.R", "x <- 1\n")?; + let case = CliTest::with_file("test.R", "1 + 1\n")?; let output = case .command() @@ -247,7 +247,7 @@ fn test_add_jarl_ignore_no_violations() -> anyhow::Result<()> { // File should be unchanged let content = case.read_file("test.R")?; - assert_eq!(content, "x <- 1\n"); + assert_eq!(content, "1 + 1\n"); Ok(()) } @@ -307,7 +307,7 @@ fn test_add_jarl_ignore_idempotent() -> anyhow::Result<()> { fn test_add_jarl_ignore_nested_violation() -> anyhow::Result<()> { let case = CliTest::with_file( "test.R", - "x <- foo(any(is.na(y))) + "foo(any(is.na(y))) ", )?; @@ -340,7 +340,7 @@ fn test_add_jarl_ignore_nested_violation() -> anyhow::Result<()> { content, @" # jarl-ignore any_is_na: - x <- foo(any(is.na(y))) + foo(any(is.na(y))) " ); @@ -549,7 +549,7 @@ fn test_add_jarl_ignore_pipe_chain() -> anyhow::Result<()> { #[test] fn test_add_jarl_ignore_same_rule_same_line() -> anyhow::Result<()> { // Two violations of the same rule in an if condition should produce one comment - let case = CliTest::with_file("test.R", "z <- x == TRUE && any(is.na(y))")?; + let case = CliTest::with_file("test.R", "x == TRUE && any(is.na(y))")?; let output = case .command() @@ -581,7 +581,7 @@ fn test_add_jarl_ignore_same_rule_same_line() -> anyhow::Result<()> { @" # jarl-ignore redundant_equals: # jarl-ignore any_is_na: - z <- x == TRUE && any(is.na(y)) + x == TRUE && any(is.na(y)) " ); diff --git a/crates/jarl/tests/integration/comments.rs b/crates/jarl/tests/integration/comments.rs index 3cb81fccf..59bb15e0e 100644 --- a/crates/jarl/tests/integration/comments.rs +++ b/crates/jarl/tests/integration/comments.rs @@ -208,6 +208,7 @@ fn test_jarl_ignore_multiple_rules_with_extend_select() -> anyhow::Result<()> { # jarl-ignore any_is_na: first rule # jarl-ignore assignment: second rule x = any(is.na(y)) +x ", )?; @@ -245,6 +246,7 @@ foo( # jarl-ignore implicit_assignment: suppressing second arg x <- 1 ) +x ", )?; diff --git a/crates/jarl/tests/integration/statistics.rs b/crates/jarl/tests/integration/statistics.rs index 4072f9202..dfe662109 100644 --- a/crates/jarl/tests/integration/statistics.rs +++ b/crates/jarl/tests/integration/statistics.rs @@ -20,7 +20,7 @@ any(is.na(x)) any(is.na(x)) ", ), - ("test2.R", "mean(x <- 1)"), + ("test2.R", "mean(x <- 1); x"), ])?; insta::assert_snapshot!( @@ -50,7 +50,7 @@ any(is.na(x)) #[test] fn test_stats_no_violation() -> anyhow::Result<()> { - let case = CliTest::with_file("test.R", "x <- 1")?; + let case = CliTest::with_file("test.R", "1 + 1")?; insta::assert_snapshot!( &mut case From 7c20568fea78065a87d0f94c6d33ca45a9b64cb3 Mon Sep 17 00:00:00 2001 From: etiennebacher Date: Tue, 12 May 2026 18:09:14 +0100 Subject: [PATCH 06/39] handle source() calls that use symbols defined in original document --- Cargo.lock | 69 ++++++++++--------- Cargo.toml | 18 ++--- crates/jarl-core/src/check.rs | 1 + crates/jarl-core/src/checker.rs | 5 ++ .../lints/base/unused_object/unused_object.rs | 6 +- crates/jarl-semantic/Cargo.toml | 1 + crates/jarl-semantic/src/lib.rs | 65 +++++++++++++++-- 7 files changed, 115 insertions(+), 50 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a38d8e7b9..2d41bed71 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,7 +29,7 @@ dependencies = [ [[package]] name = "air_r_factory" version = "0.0.0" -source = "git+https://github.com/posit-dev/air?rev=4cbd36d552e27d8930cff1602d56bfd9ce4c1ed1#4cbd36d552e27d8930cff1602d56bfd9ce4c1ed1" +source = "git+https://github.com/posit-dev/air?rev=d2659d5b158374bf486b594625ca50abbd0ac879#d2659d5b158374bf486b594625ca50abbd0ac879" dependencies = [ "air_r_syntax", "biome_rowan", @@ -38,7 +38,7 @@ dependencies = [ [[package]] name = "air_r_formatter" version = "0.0.0" -source = "git+https://github.com/posit-dev/air?rev=4cbd36d552e27d8930cff1602d56bfd9ce4c1ed1#4cbd36d552e27d8930cff1602d56bfd9ce4c1ed1" +source = "git+https://github.com/posit-dev/air?rev=d2659d5b158374bf486b594625ca50abbd0ac879#d2659d5b158374bf486b594625ca50abbd0ac879" dependencies = [ "air_r_syntax", "biome_formatter", @@ -52,7 +52,7 @@ dependencies = [ [[package]] name = "air_r_parser" version = "0.0.0" -source = "git+https://github.com/posit-dev/air?rev=4cbd36d552e27d8930cff1602d56bfd9ce4c1ed1#4cbd36d552e27d8930cff1602d56bfd9ce4c1ed1" +source = "git+https://github.com/posit-dev/air?rev=d2659d5b158374bf486b594625ca50abbd0ac879#d2659d5b158374bf486b594625ca50abbd0ac879" dependencies = [ "air_r_factory", "air_r_syntax", @@ -68,7 +68,7 @@ dependencies = [ [[package]] name = "air_r_syntax" version = "0.0.0" -source = "git+https://github.com/posit-dev/air?rev=4cbd36d552e27d8930cff1602d56bfd9ce4c1ed1#4cbd36d552e27d8930cff1602d56bfd9ce4c1ed1" +source = "git+https://github.com/posit-dev/air?rev=d2659d5b158374bf486b594625ca50abbd0ac879#d2659d5b158374bf486b594625ca50abbd0ac879" dependencies = [ "biome_rowan", "serde", @@ -179,7 +179,7 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "biome_console" version = "0.5.7" -source = "git+https://github.com/lionel-/biome?rev=41d799cfa4cedd25625fc3f6bd7898532873f051#41d799cfa4cedd25625fc3f6bd7898532873f051" +source = "git+https://github.com/lionel-/biome?rev=a1296ea6ba363d8b8d8f02181b2a4ce9315c5ef9#a1296ea6ba363d8b8d8f02181b2a4ce9315c5ef9" dependencies = [ "biome_markup", "biome_text_size", @@ -192,7 +192,7 @@ dependencies = [ [[package]] name = "biome_deserialize" version = "0.6.0" -source = "git+https://github.com/lionel-/biome?rev=41d799cfa4cedd25625fc3f6bd7898532873f051#41d799cfa4cedd25625fc3f6bd7898532873f051" +source = "git+https://github.com/lionel-/biome?rev=a1296ea6ba363d8b8d8f02181b2a4ce9315c5ef9#a1296ea6ba363d8b8d8f02181b2a4ce9315c5ef9" dependencies = [ "biome_console", "biome_diagnostics", @@ -207,7 +207,7 @@ dependencies = [ [[package]] name = "biome_deserialize_macros" version = "0.6.0" -source = "git+https://github.com/lionel-/biome?rev=41d799cfa4cedd25625fc3f6bd7898532873f051#41d799cfa4cedd25625fc3f6bd7898532873f051" +source = "git+https://github.com/lionel-/biome?rev=a1296ea6ba363d8b8d8f02181b2a4ce9315c5ef9#a1296ea6ba363d8b8d8f02181b2a4ce9315c5ef9" dependencies = [ "biome_string_case", "proc-macro-error2", @@ -219,7 +219,7 @@ dependencies = [ [[package]] name = "biome_diagnostics" version = "0.5.7" -source = "git+https://github.com/lionel-/biome?rev=41d799cfa4cedd25625fc3f6bd7898532873f051#41d799cfa4cedd25625fc3f6bd7898532873f051" +source = "git+https://github.com/lionel-/biome?rev=a1296ea6ba363d8b8d8f02181b2a4ce9315c5ef9#a1296ea6ba363d8b8d8f02181b2a4ce9315c5ef9" dependencies = [ "backtrace", "biome_console", @@ -239,7 +239,7 @@ dependencies = [ [[package]] name = "biome_diagnostics_categories" version = "0.5.7" -source = "git+https://github.com/lionel-/biome?rev=41d799cfa4cedd25625fc3f6bd7898532873f051#41d799cfa4cedd25625fc3f6bd7898532873f051" +source = "git+https://github.com/lionel-/biome?rev=a1296ea6ba363d8b8d8f02181b2a4ce9315c5ef9#a1296ea6ba363d8b8d8f02181b2a4ce9315c5ef9" dependencies = [ "quote", "serde", @@ -248,7 +248,7 @@ dependencies = [ [[package]] name = "biome_diagnostics_macros" version = "0.5.7" -source = "git+https://github.com/lionel-/biome?rev=41d799cfa4cedd25625fc3f6bd7898532873f051#41d799cfa4cedd25625fc3f6bd7898532873f051" +source = "git+https://github.com/lionel-/biome?rev=a1296ea6ba363d8b8d8f02181b2a4ce9315c5ef9#a1296ea6ba363d8b8d8f02181b2a4ce9315c5ef9" dependencies = [ "proc-macro-error2", "proc-macro2", @@ -259,7 +259,7 @@ dependencies = [ [[package]] name = "biome_formatter" version = "0.5.7" -source = "git+https://github.com/lionel-/biome?rev=41d799cfa4cedd25625fc3f6bd7898532873f051#41d799cfa4cedd25625fc3f6bd7898532873f051" +source = "git+https://github.com/lionel-/biome?rev=a1296ea6ba363d8b8d8f02181b2a4ce9315c5ef9#a1296ea6ba363d8b8d8f02181b2a4ce9315c5ef9" dependencies = [ "biome_console", "biome_deserialize", @@ -280,7 +280,7 @@ dependencies = [ [[package]] name = "biome_json_factory" version = "0.5.7" -source = "git+https://github.com/lionel-/biome?rev=41d799cfa4cedd25625fc3f6bd7898532873f051#41d799cfa4cedd25625fc3f6bd7898532873f051" +source = "git+https://github.com/lionel-/biome?rev=a1296ea6ba363d8b8d8f02181b2a4ce9315c5ef9#a1296ea6ba363d8b8d8f02181b2a4ce9315c5ef9" dependencies = [ "biome_json_syntax", "biome_rowan", @@ -289,7 +289,7 @@ dependencies = [ [[package]] name = "biome_json_parser" version = "0.5.7" -source = "git+https://github.com/lionel-/biome?rev=41d799cfa4cedd25625fc3f6bd7898532873f051#41d799cfa4cedd25625fc3f6bd7898532873f051" +source = "git+https://github.com/lionel-/biome?rev=a1296ea6ba363d8b8d8f02181b2a4ce9315c5ef9#a1296ea6ba363d8b8d8f02181b2a4ce9315c5ef9" dependencies = [ "biome_console", "biome_diagnostics", @@ -305,7 +305,7 @@ dependencies = [ [[package]] name = "biome_json_syntax" version = "0.5.7" -source = "git+https://github.com/lionel-/biome?rev=41d799cfa4cedd25625fc3f6bd7898532873f051#41d799cfa4cedd25625fc3f6bd7898532873f051" +source = "git+https://github.com/lionel-/biome?rev=a1296ea6ba363d8b8d8f02181b2a4ce9315c5ef9#a1296ea6ba363d8b8d8f02181b2a4ce9315c5ef9" dependencies = [ "biome_rowan", "biome_string_case", @@ -316,7 +316,7 @@ dependencies = [ [[package]] name = "biome_markup" version = "0.5.7" -source = "git+https://github.com/lionel-/biome?rev=41d799cfa4cedd25625fc3f6bd7898532873f051#41d799cfa4cedd25625fc3f6bd7898532873f051" +source = "git+https://github.com/lionel-/biome?rev=a1296ea6ba363d8b8d8f02181b2a4ce9315c5ef9#a1296ea6ba363d8b8d8f02181b2a4ce9315c5ef9" dependencies = [ "proc-macro-error2", "proc-macro2", @@ -326,7 +326,7 @@ dependencies = [ [[package]] name = "biome_parser" version = "0.5.7" -source = "git+https://github.com/lionel-/biome?rev=41d799cfa4cedd25625fc3f6bd7898532873f051#41d799cfa4cedd25625fc3f6bd7898532873f051" +source = "git+https://github.com/lionel-/biome?rev=a1296ea6ba363d8b8d8f02181b2a4ce9315c5ef9#a1296ea6ba363d8b8d8f02181b2a4ce9315c5ef9" dependencies = [ "biome_console", "biome_diagnostics", @@ -340,7 +340,7 @@ dependencies = [ [[package]] name = "biome_rowan" version = "0.5.7" -source = "git+https://github.com/lionel-/biome?rev=41d799cfa4cedd25625fc3f6bd7898532873f051#41d799cfa4cedd25625fc3f6bd7898532873f051" +source = "git+https://github.com/lionel-/biome?rev=a1296ea6ba363d8b8d8f02181b2a4ce9315c5ef9#a1296ea6ba363d8b8d8f02181b2a4ce9315c5ef9" dependencies = [ "biome_text_edit", "biome_text_size", @@ -352,7 +352,7 @@ dependencies = [ [[package]] name = "biome_string_case" version = "0.5.7" -source = "git+https://github.com/lionel-/biome?rev=41d799cfa4cedd25625fc3f6bd7898532873f051#41d799cfa4cedd25625fc3f6bd7898532873f051" +source = "git+https://github.com/lionel-/biome?rev=a1296ea6ba363d8b8d8f02181b2a4ce9315c5ef9#a1296ea6ba363d8b8d8f02181b2a4ce9315c5ef9" dependencies = [ "biome_rowan", ] @@ -360,7 +360,7 @@ dependencies = [ [[package]] name = "biome_text_edit" version = "0.5.7" -source = "git+https://github.com/lionel-/biome?rev=41d799cfa4cedd25625fc3f6bd7898532873f051#41d799cfa4cedd25625fc3f6bd7898532873f051" +source = "git+https://github.com/lionel-/biome?rev=a1296ea6ba363d8b8d8f02181b2a4ce9315c5ef9#a1296ea6ba363d8b8d8f02181b2a4ce9315c5ef9" dependencies = [ "biome_text_size", "serde", @@ -370,7 +370,7 @@ dependencies = [ [[package]] name = "biome_text_size" version = "0.5.7" -source = "git+https://github.com/lionel-/biome?rev=41d799cfa4cedd25625fc3f6bd7898532873f051#41d799cfa4cedd25625fc3f6bd7898532873f051" +source = "git+https://github.com/lionel-/biome?rev=a1296ea6ba363d8b8d8f02181b2a4ce9315c5ef9#a1296ea6ba363d8b8d8f02181b2a4ce9315c5ef9" dependencies = [ "serde", ] @@ -378,7 +378,7 @@ dependencies = [ [[package]] name = "biome_unicode_table" version = "0.5.7" -source = "git+https://github.com/lionel-/biome?rev=41d799cfa4cedd25625fc3f6bd7898532873f051#41d799cfa4cedd25625fc3f6bd7898532873f051" +source = "git+https://github.com/lionel-/biome?rev=a1296ea6ba363d8b8d8f02181b2a4ce9315c5ef9#a1296ea6ba363d8b8d8f02181b2a4ce9315c5ef9" [[package]] name = "bitflags" @@ -538,7 +538,7 @@ dependencies = [ [[package]] name = "comments" version = "0.0.0" -source = "git+https://github.com/posit-dev/air?rev=4cbd36d552e27d8930cff1602d56bfd9ce4c1ed1#4cbd36d552e27d8930cff1602d56bfd9ce4c1ed1" +source = "git+https://github.com/posit-dev/air?rev=d2659d5b158374bf486b594625ca50abbd0ac879#d2659d5b158374bf486b594625ca50abbd0ac879" [[package]] name = "console" @@ -789,7 +789,7 @@ dependencies = [ [[package]] name = "fs" version = "0.0.0" -source = "git+https://github.com/posit-dev/air?rev=4cbd36d552e27d8930cff1602d56bfd9ce4c1ed1#4cbd36d552e27d8930cff1602d56bfd9ce4c1ed1" +source = "git+https://github.com/posit-dev/air?rev=d2659d5b158374bf486b594625ca50abbd0ac879#d2659d5b158374bf486b594625ca50abbd0ac879" dependencies = [ "path-absolutize", ] @@ -1205,6 +1205,7 @@ dependencies = [ "oak_core", "oak_index", "oak_index_vec", + "url", ] [[package]] @@ -1246,7 +1247,7 @@ checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "line_ending" version = "0.0.0" -source = "git+https://github.com/posit-dev/air?rev=4cbd36d552e27d8930cff1602d56bfd9ce4c1ed1#4cbd36d552e27d8930cff1602d56bfd9ce4c1ed1" +source = "git+https://github.com/posit-dev/air?rev=d2659d5b158374bf486b594625ca50abbd0ac879#d2659d5b158374bf486b594625ca50abbd0ac879" dependencies = [ "memchr", "settings", @@ -1333,7 +1334,7 @@ dependencies = [ [[package]] name = "oak_core" version = "0.1.0" -source = "git+https://github.com/posit-dev/ark?rev=61f541f7ad201cc14a556ca54d3d933a7284f286#61f541f7ad201cc14a556ca54d3d933a7284f286" +source = "git+https://github.com/posit-dev/ark?rev=b3dd6facdadd901df25c3d53450771bb8ecbb2ed#b3dd6facdadd901df25c3d53450771bb8ecbb2ed" dependencies = [ "air_r_syntax", "biome_rowan", @@ -1342,7 +1343,7 @@ dependencies = [ [[package]] name = "oak_fs" version = "0.1.0" -source = "git+https://github.com/posit-dev/ark?rev=61f541f7ad201cc14a556ca54d3d933a7284f286#61f541f7ad201cc14a556ca54d3d933a7284f286" +source = "git+https://github.com/posit-dev/ark?rev=b3dd6facdadd901df25c3d53450771bb8ecbb2ed#b3dd6facdadd901df25c3d53450771bb8ecbb2ed" dependencies = [ "anyhow", "libc", @@ -1353,7 +1354,7 @@ dependencies = [ [[package]] name = "oak_index" version = "0.1.0" -source = "git+https://github.com/posit-dev/ark?rev=61f541f7ad201cc14a556ca54d3d933a7284f286#61f541f7ad201cc14a556ca54d3d933a7284f286" +source = "git+https://github.com/posit-dev/ark?rev=b3dd6facdadd901df25c3d53450771bb8ecbb2ed#b3dd6facdadd901df25c3d53450771bb8ecbb2ed" dependencies = [ "air_r_parser", "air_r_syntax", @@ -1375,7 +1376,7 @@ dependencies = [ [[package]] name = "oak_index_vec" version = "0.1.0" -source = "git+https://github.com/posit-dev/ark?rev=61f541f7ad201cc14a556ca54d3d933a7284f286#61f541f7ad201cc14a556ca54d3d933a7284f286" +source = "git+https://github.com/posit-dev/ark?rev=b3dd6facdadd901df25c3d53450771bb8ecbb2ed#b3dd6facdadd901df25c3d53450771bb8ecbb2ed" dependencies = [ "biome_text_size", "oak_core", @@ -1384,7 +1385,7 @@ dependencies = [ [[package]] name = "oak_package_metadata" version = "0.1.0" -source = "git+https://github.com/posit-dev/ark?rev=61f541f7ad201cc14a556ca54d3d933a7284f286#61f541f7ad201cc14a556ca54d3d933a7284f286" +source = "git+https://github.com/posit-dev/ark?rev=b3dd6facdadd901df25c3d53450771bb8ecbb2ed#b3dd6facdadd901df25c3d53450771bb8ecbb2ed" dependencies = [ "air_r_parser", "air_r_syntax", @@ -1398,7 +1399,7 @@ dependencies = [ [[package]] name = "oak_r_process" version = "0.1.0" -source = "git+https://github.com/posit-dev/ark?rev=61f541f7ad201cc14a556ca54d3d933a7284f286#61f541f7ad201cc14a556ca54d3d933a7284f286" +source = "git+https://github.com/posit-dev/ark?rev=b3dd6facdadd901df25c3d53450771bb8ecbb2ed#b3dd6facdadd901df25c3d53450771bb8ecbb2ed" dependencies = [ "anyhow", "log", @@ -1408,7 +1409,7 @@ dependencies = [ [[package]] name = "oak_sources" version = "0.1.0" -source = "git+https://github.com/posit-dev/ark?rev=61f541f7ad201cc14a556ca54d3d933a7284f286#61f541f7ad201cc14a556ca54d3d933a7284f286" +source = "git+https://github.com/posit-dev/ark?rev=b3dd6facdadd901df25c3d53450771bb8ecbb2ed#b3dd6facdadd901df25c3d53450771bb8ecbb2ed" dependencies = [ "anyhow", "chrono", @@ -1808,7 +1809,7 @@ dependencies = [ [[package]] name = "settings" version = "0.0.0" -source = "git+https://github.com/posit-dev/air?rev=4cbd36d552e27d8930cff1602d56bfd9ce4c1ed1#4cbd36d552e27d8930cff1602d56bfd9ce4c1ed1" +source = "git+https://github.com/posit-dev/air?rev=d2659d5b158374bf486b594625ca50abbd0ac879#d2659d5b158374bf486b594625ca50abbd0ac879" dependencies = [ "biome_formatter", "serde", @@ -1886,7 +1887,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "stdext" version = "0.1.0" -source = "git+https://github.com/posit-dev/ark?rev=61f541f7ad201cc14a556ca54d3d933a7284f286#61f541f7ad201cc14a556ca54d3d933a7284f286" +source = "git+https://github.com/posit-dev/ark?rev=b3dd6facdadd901df25c3d53450771bb8ecbb2ed#b3dd6facdadd901df25c3d53450771bb8ecbb2ed" dependencies = [ "anyhow", "log", @@ -2839,7 +2840,7 @@ dependencies = [ [[package]] name = "workspace" version = "0.1.0" -source = "git+https://github.com/posit-dev/air?rev=4cbd36d552e27d8930cff1602d56bfd9ce4c1ed1#4cbd36d552e27d8930cff1602d56bfd9ce4c1ed1" +source = "git+https://github.com/posit-dev/air?rev=d2659d5b158374bf486b594625ca50abbd0ac879#d2659d5b158374bf486b594625ca50abbd0ac879" dependencies = [ "air_r_formatter", "air_r_parser", diff --git a/Cargo.toml b/Cargo.toml index 6cc040a57..d0719dd43 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,17 +11,17 @@ repository = "https://github.com/etiennebacher/jarl/" readme = "README.md" [workspace.dependencies] -air_fs = { package = "fs", git = "https://github.com/posit-dev/air", rev = "4cbd36d552e27d8930cff1602d56bfd9ce4c1ed1" } -air_r_parser = { git = "https://github.com/posit-dev/air", rev = "4cbd36d552e27d8930cff1602d56bfd9ce4c1ed1" } -air_r_syntax = { git = "https://github.com/posit-dev/air", rev = "4cbd36d552e27d8930cff1602d56bfd9ce4c1ed1" } -air_workspace = { package = "workspace", git = "https://github.com/posit-dev/air", rev = "4cbd36d552e27d8930cff1602d56bfd9ce4c1ed1" } -oak_core = { git = "https://github.com/posit-dev/ark", rev = "61f541f7ad201cc14a556ca54d3d933a7284f286" } -oak_index = { git = "https://github.com/posit-dev/ark", rev = "61f541f7ad201cc14a556ca54d3d933a7284f286" } -oak_index_vec = { git = "https://github.com/posit-dev/ark", rev = "61f541f7ad201cc14a556ca54d3d933a7284f286" } +air_fs = { package = "fs", git = "https://github.com/posit-dev/air", rev = "d2659d5b158374bf486b594625ca50abbd0ac879" } +air_r_parser = { git = "https://github.com/posit-dev/air", rev = "d2659d5b158374bf486b594625ca50abbd0ac879" } +air_r_syntax = { git = "https://github.com/posit-dev/air", rev = "d2659d5b158374bf486b594625ca50abbd0ac879" } +air_workspace = { package = "workspace", git = "https://github.com/posit-dev/air", rev = "d2659d5b158374bf486b594625ca50abbd0ac879" } +oak_core = { git = "https://github.com/posit-dev/ark", rev = "b3dd6facdadd901df25c3d53450771bb8ecbb2ed" } +oak_index = { git = "https://github.com/posit-dev/ark", rev = "b3dd6facdadd901df25c3d53450771bb8ecbb2ed" } +oak_index_vec = { git = "https://github.com/posit-dev/ark", rev = "b3dd6facdadd901df25c3d53450771bb8ecbb2ed" } url = "2.5" anyhow = "1.0.94" -biome_formatter = { git = "https://github.com/lionel-/biome", rev = "41d799cfa4cedd25625fc3f6bd7898532873f051" } -biome_rowan = { git = "https://github.com/lionel-/biome", rev = "41d799cfa4cedd25625fc3f6bd7898532873f051" } +biome_formatter = { git = "https://github.com/lionel-/biome", rev = "a1296ea6ba363d8b8d8f02181b2a4ce9315c5ef9" } +biome_rowan = { git = "https://github.com/lionel-/biome", rev = "a1296ea6ba363d8b8d8f02181b2a4ce9315c5ef9" } clap = { version = "4.6.0", features = ["derive"] } colored = "3.1.1" ignore = "0.4.23" diff --git a/crates/jarl-core/src/check.rs b/crates/jarl-core/src/check.rs index ecd28d5cd..42a3dd78b 100644 --- a/crates/jarl-core/src/check.rs +++ b/crates/jarl-core/src/check.rs @@ -201,6 +201,7 @@ pub fn get_checks( url::Url::parse(&format!("file:///{}", file.display())).expect("synthetic URL parses") }); let semantic = oak_index::semantic_index(&parsed.tree(), &file_url); + checker.file_path = file.to_path_buf(); // Wire up package context for package-specific rules. get_package_info( diff --git a/crates/jarl-core/src/checker.rs b/crates/jarl-core/src/checker.rs index 6d33f638b..4acc1b9a0 100644 --- a/crates/jarl-core/src/checker.rs +++ b/crates/jarl-core/src/checker.rs @@ -74,6 +74,10 @@ pub struct Checker { // `S3method()`, etc.). Used to suppress false positives in rules // like `unused_object` — exported names are "used" by definition. pub namespace_exports: HashSet, + // Path of the file being checked. Used by rules that need to resolve + // paths relative to the current file (e.g. `unused_object` resolving + // `source("...")` arguments). + pub file_path: std::path::PathBuf, } impl Checker { @@ -91,6 +95,7 @@ impl Checker { package_cache: None, import_from: HashMap::new(), namespace_exports: HashSet::new(), + file_path: std::path::PathBuf::new(), } } diff --git a/crates/jarl-core/src/lints/base/unused_object/unused_object.rs b/crates/jarl-core/src/lints/base/unused_object/unused_object.rs index 9b01fc3e2..01fb31af9 100644 --- a/crates/jarl-core/src/lints/base/unused_object/unused_object.rs +++ b/crates/jarl-core/src/lints/base/unused_object/unused_object.rs @@ -36,7 +36,7 @@ pub fn unused_object( semantic: &SemanticIndex, checker: &mut Checker, ) -> anyhow::Result<()> { - let info = SemanticInfo::build(expressions, semantic); + let info = SemanticInfo::build(expressions, semantic, &checker.file_path); let exports = &checker.namespace_exports; let mut diagnostics = Vec::new(); @@ -105,14 +105,14 @@ fn is_exported( if exports.is_empty() { return false; } - let name = semantic.symbols(scope_id).symbol_id(def.symbol()).name(); + let name = semantic.symbols(scope_id).symbol(def.symbol()).name(); exports.contains(name) } fn make_diagnostic(semantic: &SemanticIndex, scope_id: ScopeId, def: &Definition) -> Diagnostic { let name = semantic .symbols(scope_id) - .symbol_id(def.symbol()) + .symbol(def.symbol()) .name() .to_string(); let range = lhs_range_for_definition(def).unwrap_or_else(|| def.range()); diff --git a/crates/jarl-semantic/Cargo.toml b/crates/jarl-semantic/Cargo.toml index f288a3046..79d400e81 100644 --- a/crates/jarl-semantic/Cargo.toml +++ b/crates/jarl-semantic/Cargo.toml @@ -20,3 +20,4 @@ biome_rowan.workspace = true oak_core.workspace = true oak_index.workspace = true oak_index_vec.workspace = true +url.workspace = true diff --git a/crates/jarl-semantic/src/lib.rs b/crates/jarl-semantic/src/lib.rs index 5d7cdf4b6..3d3c0ff6a 100644 --- a/crates/jarl-semantic/src/lib.rs +++ b/crates/jarl-semantic/src/lib.rs @@ -26,6 +26,9 @@ use oak_index::semantic_index::{Definition, DefinitionKind, ScopeId, SemanticInd /// passes over the syntax tree. Computed once per file; consumed by lints. pub struct SemanticInfo<'a> { index: &'a SemanticIndex, + /// Path of the file being analyzed. Used to resolve `source("path")` + /// arguments against the current file's directory. + file: &'a std::path::Path, /// Names that have a synthetic use from AST passes (string interpolation, /// `do.call("f", …)`, `..cols`, `on.exit` bodies, loop assignment LHSes, /// short-circuit assignment LHSes). A definition whose symbol name is in @@ -52,9 +55,14 @@ impl<'a> SemanticInfo<'a> { /// Build the info table. Runs both the AST pass (collecting synthetic /// uses, NSE ranges, formula ranges, local body ranges, callee ranges) /// and the closure call-site analysis. - pub fn build(expressions: &[RSyntaxNode], index: &'a SemanticIndex) -> Self { + pub fn build( + expressions: &[RSyntaxNode], + index: &'a SemanticIndex, + file: &'a std::path::Path, + ) -> Self { let mut this = Self { index, + file, synthetic_used_names: HashSet::new(), nse_ranges: Vec::new(), local_body_ranges: Vec::new(), @@ -95,7 +103,7 @@ impl<'a> SemanticInfo<'a> { def_id: DefinitionId, def: &Definition, ) -> bool { - let symbol_name = self.index.symbols(scope_id).symbol_id(def.symbol()).name(); + let symbol_name = self.index.symbols(scope_id).symbol(def.symbol()).name(); if self.synthetic_used_names.contains(symbol_name) { return true; } @@ -322,10 +330,43 @@ impl<'a> SemanticInfo<'a> { self.collect_on_exit_uses(body); } } + "source" => { + if let Some((_, first)) = arg_values.first() + && let Some(path) = string_literal_value(first) + { + self.import_uses_from_sourced_file(&path); + } + } _ => {} } } + /// Resolves a `source("path")` argument against the current file, parses + /// the target, and harvests every identifier appearing in it as a + /// synthetic use. R's `source()` runs its argument in the caller's + /// environment, so any name read by the sourced script consumes a + /// binding in this file. + fn import_uses_from_sourced_file(&mut self, path: &str) { + let Some(target) = resolve_sourced_path(self.file, path) else { + return; + }; + let Ok(contents) = std::fs::read_to_string(&target) else { + return; + }; + let parsed = air_r_parser::parse(&contents, RParserOptions::default()); + if parsed.has_error() { + return; + } + for node in parsed.syntax().descendants() { + if node.kind() == RSyntaxKind::R_IDENTIFIER + && let Some(token) = node.first_token() + { + self.synthetic_used_names + .insert(token.text_trimmed().to_string()); + } + } + } + fn collect_on_exit_uses(&mut self, body: &RSyntaxNode) { for node in body.descendants() { if node.kind() == RSyntaxKind::R_IDENTIFIER @@ -401,7 +442,7 @@ impl<'a> SemanticInfo<'a> { let name = self .index .symbols(parent) - .symbol_id(def.symbol()) + .symbol(def.symbol()) .name() .to_string(); binding_name = Some(name); @@ -473,7 +514,7 @@ impl<'a> SemanticInfo<'a> { let name = self .index .symbols(descendant) - .symbol_id(u.symbol()) + .symbol(u.symbol()) .name() .to_string(); if !seen.insert(name.clone()) { @@ -682,3 +723,19 @@ fn string_literal_value(node: &RSyntaxNode) -> Option { None } } + +/// Resolve a `source("path")` argument against the currently-analyzed file. +/// Absolute paths are taken as-is; relative paths are joined to the +/// directory containing the analyzed file. When the analyzed file has no +/// parent directory (e.g. just a bare filename), the relative path is +/// returned as-is — `std::fs` will resolve it against the process CWD. +fn resolve_sourced_path(current_file: &std::path::Path, path: &str) -> Option { + let candidate = std::path::Path::new(path); + if candidate.is_absolute() { + return Some(candidate.to_path_buf()); + } + match current_file.parent() { + Some(dir) if !dir.as_os_str().is_empty() => Some(dir.join(candidate)), + _ => Some(candidate.to_path_buf()), + } +} From 061df2edaf4b1c65fa1268194a0a1e38e00307ea Mon Sep 17 00:00:00 2001 From: etiennebacher Date: Tue, 12 May 2026 18:26:02 +0100 Subject: [PATCH 07/39] more tests for sourced files --- .../src/lints/base/unused_object/mod.rs | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) diff --git a/crates/jarl-core/src/lints/base/unused_object/mod.rs b/crates/jarl-core/src/lints/base/unused_object/mod.rs index d665e5fe8..cdf3307b6 100644 --- a/crates/jarl-core/src/lints/base/unused_object/mod.rs +++ b/crates/jarl-core/src/lints/base/unused_object/mod.rs @@ -8,6 +8,74 @@ mod tests { format_diagnostics(code, "unused_object", None) } + /// Renders the `unused_object` diagnostics produced by linting `main_path` + /// (already written to disk), formatted for snapshot comparison. + fn snapshot_unused_object_at(main_path: &std::path::Path, main: &str) -> String { + use crate::check::check; + use crate::config::ArgsConfig; + use crate::diagnostic::render_diagnostic; + use annotate_snippets::Renderer; + + let args = ArgsConfig { + files: vec![main_path.to_path_buf()], + fix: false, + unsafe_fixes: false, + fix_only: false, + select: "unused_object".to_string(), + extend_select: String::new(), + ignore: String::new(), + min_r_version: None, + allow_dirty: false, + allow_no_vcs: true, + assignment: None, + }; + let config = crate::config::build_config(&args, None, vec![main_path.to_path_buf()]) + .expect("build config"); + + let diagnostics: Vec<_> = check(config) + .into_iter() + .find_map(|(_, result)| result.ok()) + .unwrap_or_default(); + + if diagnostics.is_empty() { + return "All checks passed!".to_string(); + } + let renderer = Renderer::plain(); + let mut output = String::new(); + for diagnostic in &diagnostics { + let rendered = render_diagnostic( + main, + "", + &diagnostic.message.name, + diagnostic, + &renderer, + ); + output.push_str(&format!("{}\n", rendered)); + } + output.push_str(&format!( + "Found {} error{}.", + diagnostics.len(), + if diagnostics.len() == 1 { "" } else { "s" } + )); + output + } + + /// Lints `main.R` inside a fresh tempdir after populating that directory + /// with the named (filename, content) pairs, and renders diagnostics as + /// a snapshot string. Used for `source()` resolution tests where the + /// sourced file lives next to the linted file. + fn snapshot_lint_with_sourced_files(main: &str, files: &[(&str, &str)]) -> String { + use std::fs; + + let dir = tempfile::tempdir().expect("create tempdir"); + let main_path = dir.path().join("main.R"); + fs::write(&main_path, main).expect("write main.R"); + for (name, content) in files { + fs::write(dir.path().join(name), content).expect("write sourced file"); + } + snapshot_unused_object_at(&main_path, main) + } + #[test] fn test_no_lint_used_variable() { expect_no_lint("x <- 1\nprint(x)", "unused_object", None); @@ -823,4 +891,82 @@ for (i in 1:2) { None, ); } + + // --------------------------------------------------------------- + // source() cross-file resolution + // --------------------------------------------------------------- + + #[test] + fn test_no_lint_sourced_file_reads_var() { + // `x` looks unused in main.R, but the sourced helper reads it, so + // the binding is consumed at the source() call site. + assert_snapshot!( + snapshot_lint_with_sourced_files( + "x <- 1\nsource(\"helper.R\")\n", + &[("helper.R", "print(x + 1)")], + ), + @"All checks passed!" + ); + } + + #[test] + fn test_lint_sourced_file_does_not_read_var() { + // The sourced helper doesn't reference `y`, so it's still unused. + assert_snapshot!( + snapshot_lint_with_sourced_files( + "y <- 1\nsource(\"helper.R\")\n", + &[("helper.R", "print(1)")], + ), + @r" + warning: unused_object + --> :1:1 + | + 1 | y <- 1 + | - Object `y` is defined but never used. + | + Found 1 error. + " + ); + } + + #[test] + fn test_lint_sourced_file_missing_does_not_suppress() { + // No helper.R on disk: resolution silently fails and we fall back + // to the regular unused-object check. + assert_snapshot!( + snapshot_lint_with_sourced_files("x <- 1\nsource(\"missing.R\")\n", &[]), + @r" + warning: unused_object + --> :1:1 + | + 1 | x <- 1 + | - Object `x` is defined but never used. + | + Found 1 error. + " + ); + } + + #[test] + fn test_no_lint_sourced_file_absolute_path_outside_project() { + // The sourced file lives in a separate tempdir, referenced by an + // absolute path. Resolution should follow the path verbatim rather + // than joining it under the linted file's directory. + use std::fs; + + let project_dir = tempfile::tempdir().expect("create project tempdir"); + let external_dir = tempfile::tempdir().expect("create external tempdir"); + + let helper_path = external_dir.path().join("helper.R"); + fs::write(&helper_path, "print(x + 1)").expect("write helper.R"); + + let main = format!( + "x <- 1\nsource(\"{}\")\n", + helper_path.to_str().expect("utf-8 path") + ); + let main_path = project_dir.path().join("main.R"); + fs::write(&main_path, &main).expect("write main.R"); + + assert_snapshot!(snapshot_unused_object_at(&main_path, &main), @"All checks passed!"); + } } From 4e394204865e0dbcebb6d138af097ab4c71b50d8 Mon Sep 17 00:00:00 2001 From: etiennebacher Date: Tue, 12 May 2026 22:48:37 +0100 Subject: [PATCH 08/39] simplify docs [skip ci] --- .../jarl-core/src/lints/base/unused_object/unused_object.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/jarl-core/src/lints/base/unused_object/unused_object.rs b/crates/jarl-core/src/lints/base/unused_object/unused_object.rs index 01fb31af9..6ecdabac8 100644 --- a/crates/jarl-core/src/lints/base/unused_object/unused_object.rs +++ b/crates/jarl-core/src/lints/base/unused_object/unused_object.rs @@ -15,10 +15,7 @@ use crate::diagnostic::{Diagnostic, Fix, ViolationData}; /// /// ## What it does /// -/// Detects local variables assigned a value that is never read. Operates on -/// oak's per-file `SemanticIndex`: walks every scope, looks at each -/// definition, and emits a warning when no `Use` reaches it (directly or -/// through a closure). +/// Detects objects that are defined (i.e. assigned a value) but never used. /// /// ## Why is this bad? /// From dc29b29416d003c230f36f90d226cb2e3a7b9542 Mon Sep 17 00:00:00 2001 From: etiennebacher Date: Fri, 22 May 2026 14:16:20 +0100 Subject: [PATCH 09/39] update --- Cargo.lock | 62 +++--- Cargo.toml | 6 +- crates/jarl-core/Cargo.toml | 2 +- crates/jarl-core/src/analyze/document.rs | 2 +- crates/jarl-core/src/check.rs | 51 +++-- crates/jarl-core/src/lib.rs | 1 - crates/jarl-core/src/library_calls.rs | 203 ------------------ .../lints/base/unused_object/unused_object.rs | 26 ++- crates/jarl-semantic/Cargo.toml | 2 +- crates/jarl-semantic/src/lib.rs | 42 ++-- 10 files changed, 119 insertions(+), 278 deletions(-) delete mode 100644 crates/jarl-core/src/library_calls.rs diff --git a/Cargo.lock b/Cargo.lock index 2d41bed71..d013a252b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1162,8 +1162,8 @@ dependencies = [ "insta", "jarl-semantic", "oak_core", - "oak_index", "oak_index_vec", + "oak_semantic", "path-absolutize", "rayon", "regex", @@ -1203,8 +1203,8 @@ dependencies = [ "air_r_syntax", "biome_rowan", "oak_core", - "oak_index", "oak_index_vec", + "oak_semantic", "url", ] @@ -1334,7 +1334,7 @@ dependencies = [ [[package]] name = "oak_core" version = "0.1.0" -source = "git+https://github.com/posit-dev/ark?rev=b3dd6facdadd901df25c3d53450771bb8ecbb2ed#b3dd6facdadd901df25c3d53450771bb8ecbb2ed" +source = "git+https://github.com/posit-dev/ark?rev=fcb9a40f3e84074298b57b05b4de68e5515c9413#fcb9a40f3e84074298b57b05b4de68e5515c9413" dependencies = [ "air_r_syntax", "biome_rowan", @@ -1343,7 +1343,7 @@ dependencies = [ [[package]] name = "oak_fs" version = "0.1.0" -source = "git+https://github.com/posit-dev/ark?rev=b3dd6facdadd901df25c3d53450771bb8ecbb2ed#b3dd6facdadd901df25c3d53450771bb8ecbb2ed" +source = "git+https://github.com/posit-dev/ark?rev=fcb9a40f3e84074298b57b05b4de68e5515c9413#fcb9a40f3e84074298b57b05b4de68e5515c9413" dependencies = [ "anyhow", "libc", @@ -1351,32 +1351,10 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "oak_index" -version = "0.1.0" -source = "git+https://github.com/posit-dev/ark?rev=b3dd6facdadd901df25c3d53450771bb8ecbb2ed#b3dd6facdadd901df25c3d53450771bb8ecbb2ed" -dependencies = [ - "air_r_parser", - "air_r_syntax", - "anyhow", - "biome_rowan", - "biome_text_size", - "itertools 0.14.0", - "log", - "oak_core", - "oak_index_vec", - "oak_package_metadata", - "oak_sources", - "rustc-hash", - "smallvec", - "stdext", - "url", -] - [[package]] name = "oak_index_vec" version = "0.1.0" -source = "git+https://github.com/posit-dev/ark?rev=b3dd6facdadd901df25c3d53450771bb8ecbb2ed#b3dd6facdadd901df25c3d53450771bb8ecbb2ed" +source = "git+https://github.com/posit-dev/ark?rev=fcb9a40f3e84074298b57b05b4de68e5515c9413#fcb9a40f3e84074298b57b05b4de68e5515c9413" dependencies = [ "biome_text_size", "oak_core", @@ -1385,7 +1363,7 @@ dependencies = [ [[package]] name = "oak_package_metadata" version = "0.1.0" -source = "git+https://github.com/posit-dev/ark?rev=b3dd6facdadd901df25c3d53450771bb8ecbb2ed#b3dd6facdadd901df25c3d53450771bb8ecbb2ed" +source = "git+https://github.com/posit-dev/ark?rev=fcb9a40f3e84074298b57b05b4de68e5515c9413#fcb9a40f3e84074298b57b05b4de68e5515c9413" dependencies = [ "air_r_parser", "air_r_syntax", @@ -1399,17 +1377,39 @@ dependencies = [ [[package]] name = "oak_r_process" version = "0.1.0" -source = "git+https://github.com/posit-dev/ark?rev=b3dd6facdadd901df25c3d53450771bb8ecbb2ed#b3dd6facdadd901df25c3d53450771bb8ecbb2ed" +source = "git+https://github.com/posit-dev/ark?rev=fcb9a40f3e84074298b57b05b4de68e5515c9413#fcb9a40f3e84074298b57b05b4de68e5515c9413" dependencies = [ "anyhow", "log", "tempfile", ] +[[package]] +name = "oak_semantic" +version = "0.1.0" +source = "git+https://github.com/posit-dev/ark?rev=fcb9a40f3e84074298b57b05b4de68e5515c9413#fcb9a40f3e84074298b57b05b4de68e5515c9413" +dependencies = [ + "air_r_parser", + "air_r_syntax", + "anyhow", + "biome_rowan", + "biome_text_size", + "itertools 0.14.0", + "log", + "oak_core", + "oak_index_vec", + "oak_package_metadata", + "oak_sources", + "rustc-hash", + "smallvec", + "stdext", + "url", +] + [[package]] name = "oak_sources" version = "0.1.0" -source = "git+https://github.com/posit-dev/ark?rev=b3dd6facdadd901df25c3d53450771bb8ecbb2ed#b3dd6facdadd901df25c3d53450771bb8ecbb2ed" +source = "git+https://github.com/posit-dev/ark?rev=fcb9a40f3e84074298b57b05b4de68e5515c9413#fcb9a40f3e84074298b57b05b4de68e5515c9413" dependencies = [ "anyhow", "chrono", @@ -1887,7 +1887,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "stdext" version = "0.1.0" -source = "git+https://github.com/posit-dev/ark?rev=b3dd6facdadd901df25c3d53450771bb8ecbb2ed#b3dd6facdadd901df25c3d53450771bb8ecbb2ed" +source = "git+https://github.com/posit-dev/ark?rev=fcb9a40f3e84074298b57b05b4de68e5515c9413#fcb9a40f3e84074298b57b05b4de68e5515c9413" dependencies = [ "anyhow", "log", diff --git a/Cargo.toml b/Cargo.toml index d0719dd43..a65f1a255 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,9 +15,9 @@ air_fs = { package = "fs", git = "https://github.com/posit-dev/air", rev = "d265 air_r_parser = { git = "https://github.com/posit-dev/air", rev = "d2659d5b158374bf486b594625ca50abbd0ac879" } air_r_syntax = { git = "https://github.com/posit-dev/air", rev = "d2659d5b158374bf486b594625ca50abbd0ac879" } air_workspace = { package = "workspace", git = "https://github.com/posit-dev/air", rev = "d2659d5b158374bf486b594625ca50abbd0ac879" } -oak_core = { git = "https://github.com/posit-dev/ark", rev = "b3dd6facdadd901df25c3d53450771bb8ecbb2ed" } -oak_index = { git = "https://github.com/posit-dev/ark", rev = "b3dd6facdadd901df25c3d53450771bb8ecbb2ed" } -oak_index_vec = { git = "https://github.com/posit-dev/ark", rev = "b3dd6facdadd901df25c3d53450771bb8ecbb2ed" } +oak_core = { git = "https://github.com/posit-dev/ark", rev = "fcb9a40f3e84074298b57b05b4de68e5515c9413" } +oak_index_vec = { git = "https://github.com/posit-dev/ark", rev = "fcb9a40f3e84074298b57b05b4de68e5515c9413" } +oak_semantic = { git = "https://github.com/posit-dev/ark", rev = "fcb9a40f3e84074298b57b05b4de68e5515c9413" } url = "2.5" anyhow = "1.0.94" biome_formatter = { git = "https://github.com/lionel-/biome", rev = "a1296ea6ba363d8b8d8f02181b2a4ce9315c5ef9" } diff --git a/crates/jarl-core/Cargo.toml b/crates/jarl-core/Cargo.toml index 1fd6492a7..e0f7c3e4b 100644 --- a/crates/jarl-core/Cargo.toml +++ b/crates/jarl-core/Cargo.toml @@ -25,8 +25,8 @@ biome_formatter.workspace = true # Semantic analysis (per-file scopes, definitions, uses, use-def map) oak_core.workspace = true -oak_index.workspace = true oak_index_vec.workspace = true +oak_semantic.workspace = true url.workspace = true # Per-file semantic info (used by semantic-aware lints) diff --git a/crates/jarl-core/src/analyze/document.rs b/crates/jarl-core/src/analyze/document.rs index e7b273c9a..8449159da 100644 --- a/crates/jarl-core/src/analyze/document.rs +++ b/crates/jarl-core/src/analyze/document.rs @@ -1,6 +1,6 @@ use air_r_syntax::{RExpressionList, RSyntaxNode}; use biome_rowan::{AstNode, AstNodeList}; -use oak_index::semantic_index::SemanticIndex; +use oak_semantic::semantic_index::SemanticIndex; use crate::checker::Checker; use crate::diagnostic::*; diff --git a/crates/jarl-core/src/check.rs b/crates/jarl-core/src/check.rs index 42a3dd78b..8b90f8d07 100644 --- a/crates/jarl-core/src/check.rs +++ b/crates/jarl-core/src/check.rs @@ -8,7 +8,7 @@ use crate::suppression::SuppressionManager; use crate::vcs::check_version_control; use air_fs::relativize_path; use air_r_parser::RParserOptions; -use air_r_syntax::{RExpressionList, RSyntaxNode}; +use air_r_syntax::RSyntaxNode; use anyhow::{Context, Result}; use rayon::prelude::*; use std::collections::HashMap; @@ -194,20 +194,21 @@ pub fn get_checks( checker.rule_set = config.rules_to_apply.clone(); checker.minimum_r_version = config.minimum_r_version; - // Build the semantic index for use-def-based rules. `Url::from_file_path` - // requires an absolute path; for relative paths (e.g. test inputs) fall - // back to a synthetic file URL so indexing always succeeds. - let file_url = url::Url::from_file_path(file).unwrap_or_else(|_| { - url::Url::parse(&format!("file:///{}", file.display())).expect("synthetic URL parses") - }); - let semantic = oak_index::semantic_index(&parsed.tree(), &file_url); + // Build the semantic index for use-def-based rules. `source("path")` + // calls inject `DefinitionKind::Import` entries via JarlImportsResolver; + // the complementary "names read by sourced files" path is still handled + // inside `SemanticInfo`. + let semantic = oak_semantic::build_index( + &parsed.tree(), + jarl_semantic::JarlImportsResolver::new(file), + ); checker.file_path = file.to_path_buf(); // Wire up package context for package-specific rules. get_package_info( &mut checker, file, - expressions, + &semantic, config, pkg_contexts, file_pkg_info, @@ -307,11 +308,12 @@ pub fn get_checks( /// Populate package context on the checker from pre-computed data. /// /// For files inside an R package, copies the pre-computed `PackageContext` -/// fields. For scripts, scans for `library()`/`require()` calls. +/// fields. For scripts, harvests `library()`/`require()` calls from the +/// semantic index. fn get_package_info( checker: &mut Checker, file: &Path, - expressions: &RExpressionList, + semantic: &oak_semantic::semantic_index::SemanticIndex, config: &Config, pkg_contexts: &HashMap, file_pkg_info: &HashMap, @@ -329,13 +331,38 @@ fn get_package_info( .iter() .map(|s| s.to_string()) .collect(); - packages.extend(crate::library_calls::extract_library_calls(expressions)); + packages.extend(top_level_attached_packages(semantic)); checker.loaded_packages = packages; } } checker.package_cache = config.package_cache.clone(); } +/// Collect package names from top-level `library()`/`require()` calls in +/// load order, deduplicated. Calls inside nested function bodies are +/// excluded because their attachment isn't statically guaranteed; calls +/// inside top-level `if`/loops are included because oak scopes them to the +/// file (R sequential execution makes their effect visible to subsequent +/// top-level code if the branch runs). +fn top_level_attached_packages( + semantic: &oak_semantic::semantic_index::SemanticIndex, +) -> Vec { + use oak_semantic::semantic_index::SemanticCallKind; + let top_level = oak_semantic::ScopeId::from(0); + let mut out: Vec = Vec::new(); + for call in semantic.semantic_calls() { + if call.scope() != top_level { + continue; + } + if let SemanticCallKind::Attach { package } = call.kind() + && !out.iter().any(|p| p == package) + { + out.push(package.clone()); + } + } + out +} + /// Lint R code inside roxygen `@examples` and `@examplesIf` sections. /// /// Each examples section is extracted, parsed as standalone R code, and linted. diff --git a/crates/jarl-core/src/lib.rs b/crates/jarl-core/src/lib.rs index 7b2502f30..4d76a20ca 100644 --- a/crates/jarl-core/src/lib.rs +++ b/crates/jarl-core/src/lib.rs @@ -20,7 +20,6 @@ pub mod discovery; pub mod error; pub mod fix; pub mod fs; -pub mod library_calls; pub mod library_paths; pub mod lints; pub mod location; diff --git a/crates/jarl-core/src/library_calls.rs b/crates/jarl-core/src/library_calls.rs deleted file mode 100644 index 0d659f071..000000000 --- a/crates/jarl-core/src/library_calls.rs +++ /dev/null @@ -1,203 +0,0 @@ -//! Extract `library()` and `require()` calls from an R file's AST. -//! -//! A lightweight pre-pass over top-level statements to determine which packages -//! are loaded, and in what order. This information is used to resolve bare -//! function names to packages. - -use air_r_syntax::*; -use biome_rowan::AstNode; - -use crate::utils::{get_function_name, get_function_namespace_prefix}; - -/// Extract package names from top-level `library()` and `require()` calls. -/// -/// Returns package names in load order. Ignores: -/// - Calls inside function bodies (conditional loading, unreliable). -/// - `library(pkg, character.only = TRUE)` (dynamic, can't resolve). -/// - Calls inside for/while loops (dynamic). -pub fn extract_library_calls(root: &RExpressionList) -> Vec { - let mut packages = Vec::new(); - - for expr in root { - extract_from_top_level(&expr, &mut packages); - } - - packages -} - -/// Process a single top-level expression. -fn extract_from_top_level(expr: &AnyRExpression, packages: &mut Vec) { - match expr { - // Direct call: `library(dplyr)` - AnyRExpression::RCall(call) => { - try_extract_library_call(call, packages); - } - // Braced block at top level (not inside a function): `{ library(dplyr) }` - AnyRExpression::RBracedExpressions(braced) => { - for inner in braced.expressions() { - extract_from_top_level(&inner, packages); - } - } - // Skip if/else, loops, pipes — conditional loading is unreliable. - _ => {} - } -} - -/// Try to extract a package name from a `library(pkg)` or `require(pkg)` call. -fn try_extract_library_call(call: &RCall, packages: &mut Vec) { - let Ok(function) = call.function() else { - return; - }; - let fn_name = get_function_name(function.clone()); - let fn_ns = get_function_namespace_prefix(function); - - // Match library() / require() / base::library() / base::require() - if fn_name != "library" && fn_name != "require" { - return; - } - if let Some(ref ns) = fn_ns - && ns != "base::" - { - return; - } - - let Ok(args) = call.arguments() else { return }; - let items: Vec<_> = args.items().into_iter().collect(); - - // Skip `library(pkg, character.only = TRUE)` — dynamic, can't resolve - let has_character_only = items.iter().any(|item| { - item.as_ref().is_ok_and(|arg| { - arg.name_clause().is_some_and(|nc| { - nc.name() - .is_ok_and(|n| n.to_trimmed_string() == "character.only") - }) && arg.value().is_some_and(|v| v.to_trimmed_string() == "TRUE") - }) - }); - if has_character_only { - return; - } - - // Get the first positional (unnamed) argument - let first_arg = items.iter().find_map(|item| { - item.as_ref().ok().and_then(|arg| { - if arg.name_clause().is_none() { - arg.value() - } else { - None - } - }) - }); - - let Some(first_arg) = first_arg else { return }; - - // Extract the package name (bare symbol or string literal) - let pkg_name = extract_package_name(&first_arg); - - if let Some(name) = pkg_name - && !name.is_empty() - && !packages.contains(&name) - { - packages.push(name); - } -} - -/// Extract a package name from the first argument of `library()`. -/// -/// Handles bare symbols (`library(dplyr)`) and string literals (`library("dplyr")`). -fn extract_package_name(expr: &AnyRExpression) -> Option { - // Bare symbol: `library(dplyr)` - if let Some(id) = expr.as_r_identifier() - && let Ok(token) = id.name_token() - { - return Some(token.token_text_trimmed().text().to_string()); - } - - // String literal: `library("dplyr")` - if let AnyRExpression::AnyRValue(value) = expr - && let AnyRValue::RStringValue(s) = value - { - let text = s.to_trimmed_string(); - let unquoted = text.trim_matches('"').trim_matches('\''); - return Some(unquoted.to_string()); - } - - None -} - -#[cfg(test)] -mod tests { - use super::*; - use air_r_parser::RParserOptions; - - fn parse_and_extract(code: &str) -> Vec { - let parsed = air_r_parser::parse(code, RParserOptions::default()); - assert!(!parsed.has_error(), "Parse error in test code: {code}"); - extract_library_calls(&parsed.tree().expressions()) - } - - #[test] - fn test_basic_library() { - assert_eq!(parse_and_extract("library(dplyr)"), vec!["dplyr"]); - } - - #[test] - fn test_string_library() { - assert_eq!(parse_and_extract("library(\"dplyr\")"), vec!["dplyr"]); - } - - #[test] - fn test_require() { - assert_eq!(parse_and_extract("require(ggplot2)"), vec!["ggplot2"]); - } - - #[test] - fn test_base_prefix() { - assert_eq!(parse_and_extract("base::library(dplyr)"), vec!["dplyr"]); - } - - #[test] - fn test_multiple_libraries() { - let code = "library(dplyr)\nlibrary(tidyr)\nlibrary(ggplot2)"; - assert_eq!(parse_and_extract(code), vec!["dplyr", "tidyr", "ggplot2"]); - } - - #[test] - fn test_no_duplicates() { - let code = "library(dplyr)\nlibrary(dplyr)"; - assert_eq!(parse_and_extract(code), vec!["dplyr"]); - } - - #[test] - fn test_character_only_ignored() { - let code = "library(pkg, character.only = TRUE)"; - assert!(parse_and_extract(code).is_empty()); - } - - #[test] - fn test_non_base_namespace_ignored() { - let code = "mypkg::library(dplyr)"; - assert!(parse_and_extract(code).is_empty()); - } - - #[test] - fn test_inside_braces() { - let code = "{\n library(dplyr)\n}"; - assert_eq!(parse_and_extract(code), vec!["dplyr"]); - } - - #[test] - fn test_inside_if_ignored() { - let code = "if (TRUE) library(dplyr)"; - assert!(parse_and_extract(code).is_empty()); - } - - #[test] - fn test_empty_file() { - assert!(parse_and_extract("").is_empty()); - } - - #[test] - fn test_no_library_calls() { - assert!(parse_and_extract("x <- 1\ny <- 2").is_empty()); - } -} diff --git a/crates/jarl-core/src/lints/base/unused_object/unused_object.rs b/crates/jarl-core/src/lints/base/unused_object/unused_object.rs index 6ecdabac8..51de61819 100644 --- a/crates/jarl-core/src/lints/base/unused_object/unused_object.rs +++ b/crates/jarl-core/src/lints/base/unused_object/unused_object.rs @@ -1,7 +1,7 @@ use air_r_syntax::{RBinaryExpression, RSyntaxKind, RSyntaxNode}; use biome_rowan::{AstNode, SyntaxNodeCast}; use oak_core::syntax_ext::RIdentifierExt; -use oak_index::semantic_index::{Definition, DefinitionKind, ScopeId, SemanticIndex}; +use oak_semantic::semantic_index::{Definition, DefinitionKind, ScopeId, SemanticIndex}; use jarl_semantic::{ SemanticInfo, assignment_lhs_is_complex, assignment_rhs_is_function_def, @@ -33,7 +33,11 @@ pub fn unused_object( semantic: &SemanticIndex, checker: &mut Checker, ) -> anyhow::Result<()> { - let info = SemanticInfo::build(expressions, semantic, &checker.file_path); + let Some(first) = expressions.first() else { + return Ok(()); + }; + let root = first.ancestors().last().unwrap_or_else(|| first.clone()); + let info = SemanticInfo::build(&root, expressions, semantic, &checker.file_path); let exports = &checker.namespace_exports; let mut diagnostics = Vec::new(); @@ -49,7 +53,7 @@ pub fn unused_object( if scope_id == top_level && is_exported(semantic, exports, scope_id, def) { continue; } - diagnostics.push(make_diagnostic(semantic, scope_id, def)); + diagnostics.push(make_diagnostic(semantic, scope_id, def, info.root())); } } diagnostics.extend(collect_assignment_pipe_diagnostics( @@ -72,14 +76,15 @@ fn should_lint_definition(info: &SemanticInfo<'_>, def: &Definition) -> bool { | DefinitionKind::ForVariable(_) | DefinitionKind::SuperAssignment(_) | DefinitionKind::Import { .. } => return false, - DefinitionKind::Assignment(node) => { - if assignment_rhs_is_function_def(node) { + DefinitionKind::Assignment(ptr) => { + let bin = ptr.to_node(info.root()); + if assignment_rhs_is_function_def(&bin) { return false; } // Replacement-function or subset assignment LHS (`names(x) <-`, // `x[1] <-`, `x$a <-`): the LHS construct reads `x` so the // surrounding binding is still considered used. - if assignment_lhs_is_complex(node) { + if assignment_lhs_is_complex(&bin) { return false; } } @@ -106,13 +111,18 @@ fn is_exported( exports.contains(name) } -fn make_diagnostic(semantic: &SemanticIndex, scope_id: ScopeId, def: &Definition) -> Diagnostic { +fn make_diagnostic( + semantic: &SemanticIndex, + scope_id: ScopeId, + def: &Definition, + root: &RSyntaxNode, +) -> Diagnostic { let name = semantic .symbols(scope_id) .symbol(def.symbol()) .name() .to_string(); - let range = lhs_range_for_definition(def).unwrap_or_else(|| def.range()); + let range = lhs_range_for_definition(def, root).unwrap_or_else(|| def.range()); Diagnostic::new( ViolationData::new( "unused_object".to_string(), diff --git a/crates/jarl-semantic/Cargo.toml b/crates/jarl-semantic/Cargo.toml index 79d400e81..ad43487da 100644 --- a/crates/jarl-semantic/Cargo.toml +++ b/crates/jarl-semantic/Cargo.toml @@ -18,6 +18,6 @@ air_r_syntax.workspace = true biome_rowan.workspace = true oak_core.workspace = true -oak_index.workspace = true oak_index_vec.workspace = true +oak_semantic.workspace = true url.workspace = true diff --git a/crates/jarl-semantic/src/lib.rs b/crates/jarl-semantic/src/lib.rs index 3d3c0ff6a..d57aeaf7e 100644 --- a/crates/jarl-semantic/src/lib.rs +++ b/crates/jarl-semantic/src/lib.rs @@ -19,13 +19,16 @@ use air_r_syntax::{ }; use biome_rowan::{AstNode, AstSeparatedList, SyntaxNodeCast, TextRange, TextSize}; use oak_core::syntax_ext::RIdentifierExt; -use oak_index::DefinitionId; -use oak_index::semantic_index::{Definition, DefinitionKind, ScopeId, SemanticIndex, SymbolId}; +use oak_semantic::DefinitionId; +use oak_semantic::semantic_index::{Definition, DefinitionKind, ScopeId, SemanticIndex, SymbolId}; /// Per-file semantic info derived from oak's [`SemanticIndex`] plus AST /// passes over the syntax tree. Computed once per file; consumed by lints. pub struct SemanticInfo<'a> { index: &'a SemanticIndex, + /// Root syntax node of the analyzed file. Needed to resolve + /// `AstPtr` references stored in [`DefinitionKind`] back to nodes. + root: RSyntaxNode, /// Path of the file being analyzed. Used to resolve `source("path")` /// arguments against the current file's directory. file: &'a std::path::Path, @@ -56,12 +59,14 @@ impl<'a> SemanticInfo<'a> { /// uses, NSE ranges, formula ranges, local body ranges, callee ranges) /// and the closure call-site analysis. pub fn build( + root: &RSyntaxNode, expressions: &[RSyntaxNode], index: &'a SemanticIndex, file: &'a std::path::Path, ) -> Self { let mut this = Self { index, + root: root.clone(), file, synthetic_used_names: HashSet::new(), nse_ranges: Vec::new(), @@ -81,6 +86,10 @@ impl<'a> SemanticInfo<'a> { self.index } + pub fn root(&self) -> &RSyntaxNode { + &self.root + } + /// Walk all scopes (root + descendants) in arbitrary order. pub fn scope_ids(&self) -> Vec { let mut ids = Vec::new(); @@ -435,9 +444,12 @@ impl<'a> SemanticInfo<'a> { let mut binding_name: Option = None; for (_, def) in self.index.definitions(parent).iter() { - if let DefinitionKind::Assignment(node) = def.kind() - && node.text_trimmed_range().contains_range(child_range) - && assignment_rhs_is_function_def(node) + if let DefinitionKind::Assignment(ptr) = def.kind() + && ptr + .syntax_node_ptr() + .text_range() + .contains_range(child_range) + && assignment_rhs_is_function_def(&ptr.to_node(&self.root)) { let name = self .index @@ -588,8 +600,8 @@ fn is_member_name(node: &RSyntaxNode) -> bool { } /// True if the RHS of a binary assignment is a function definition. -pub fn assignment_rhs_is_function_def(node: &RSyntaxNode) -> bool { - for child in node.children() { +pub fn assignment_rhs_is_function_def(bin: &RBinaryExpression) -> bool { + for child in bin.syntax().children() { if child.kind() == RSyntaxKind::R_FUNCTION_DEFINITION { return true; } @@ -599,10 +611,7 @@ pub fn assignment_rhs_is_function_def(node: &RSyntaxNode) -> bool { /// True if the LHS of a binary assignment is anything other than a bare /// identifier (e.g. `names(x)`, `x[1]`, `x$a`). -pub fn assignment_lhs_is_complex(node: &RSyntaxNode) -> bool { - let Some(bin) = node.clone().cast::() else { - return false; - }; +pub fn assignment_lhs_is_complex(bin: &RBinaryExpression) -> bool { let Ok(op) = bin.operator() else { return false; }; @@ -618,15 +627,14 @@ pub fn assignment_lhs_is_complex(node: &RSyntaxNode) -> bool { } /// The text range of the bare-identifier LHS of an assignment, if any. -pub fn lhs_range_for_definition(def: &Definition) -> Option { - let node = match def.kind() { - DefinitionKind::Assignment(n) | DefinitionKind::SuperAssignment(n) => n, - DefinitionKind::Parameter(n) | DefinitionKind::ForVariable(n) => { - return Some(n.text_trimmed_range()); +pub fn lhs_range_for_definition(def: &Definition, root: &RSyntaxNode) -> Option { + let bin = match def.kind() { + DefinitionKind::Assignment(ptr) | DefinitionKind::SuperAssignment(ptr) => ptr.to_node(root), + DefinitionKind::Parameter(_) | DefinitionKind::ForVariable(_) => { + return Some(def.range()); } DefinitionKind::Import { .. } => return None, }; - let bin = node.clone().cast::()?; let op = bin.operator().ok()?; let lhs = if op.text_trimmed() == "->" || op.text_trimmed() == "->>" { bin.right().ok()? From bfe92f6870caedf196b81f2baf7ebca3fd30be1f Mon Sep 17 00:00:00 2001 From: etiennebacher Date: Fri, 22 May 2026 14:30:24 +0100 Subject: [PATCH 10/39] fix --- crates/jarl-semantic/src/lib.rs | 46 +++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/crates/jarl-semantic/src/lib.rs b/crates/jarl-semantic/src/lib.rs index d57aeaf7e..25e9cd644 100644 --- a/crates/jarl-semantic/src/lib.rs +++ b/crates/jarl-semantic/src/lib.rs @@ -747,3 +747,49 @@ fn resolve_sourced_path(current_file: &std::path::Path, path: &str) -> Option Some(candidate.to_path_buf()), } } + +/// `ImportsResolver` impl that plugs `source("path")` injection into oak's +/// builder. +/// +/// The resolver parses the target file, builds a noop-resolved +/// `SemanticIndex` for it, and reports its top-level definitions as +/// `SourceResolution.names`. Oak then materialises `DefinitionKind::Import` +/// entries at the `source()` call site in the calling file's index. +/// +/// This handles the *defined-by-source* side of `source()` semantics. +/// The complementary *used-by-source* side — names *read* by the sourced +/// file consume bindings in the calling file — is still handled +/// separately by [`SemanticInfo::import_uses_from_sourced_file`] because +/// oak's [`oak_semantic::SourceResolution`] only carries defined names. +pub struct JarlImportsResolver { + current_file: std::path::PathBuf, +} + +impl JarlImportsResolver { + pub fn new(current_file: impl Into) -> Self { + Self { current_file: current_file.into() } + } +} + +impl oak_semantic::ImportsResolver for JarlImportsResolver { + fn resolve_source(&mut self, path: &str) -> Option { + let target = resolve_sourced_path(&self.current_file, path)?; + let contents = std::fs::read_to_string(&target).ok()?; + let parsed = air_r_parser::parse(&contents, RParserOptions::default()); + if parsed.has_error() { + return None; + } + // `Url::from_file_path` rejects relative paths; fall back to a + // synthetic `file:///` URL so test fixtures (which often use + // relative paths) still index. + let url = url::Url::from_file_path(&target) + .ok() + .or_else(|| url::Url::parse(&format!("file:///{}", target.display())).ok())?; + let sub_index = + oak_semantic::build_index(&parsed.tree(), oak_semantic::NoopImportsResolver); + // TODO: extend to transitive `source()` resolution by recursing + // through nested `SemanticCallKind::Source` entries here. + let names: Vec = sub_index.exports().keys().map(|s| s.to_string()).collect(); + Some(oak_semantic::SourceResolution { url, names, packages: Vec::new() }) + } +} From d4f6b5080c02df743187cf8cc8e121847e3a4562 Mon Sep 17 00:00:00 2001 From: etiennebacher Date: Fri, 19 Jun 2026 15:26:13 +0100 Subject: [PATCH 11/39] bumt oka [skip ci] --- Cargo.lock | 680 ++------------------------------ Cargo.toml | 8 +- crates/jarl-semantic/src/lib.rs | 6 +- 3 files changed, 45 insertions(+), 649 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0fa7950fb..6513333ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -74,15 +74,6 @@ dependencies = [ "serde", ] -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - [[package]] name = "annotate-snippets" version = "0.11.5" @@ -129,7 +120,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -140,7 +131,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -149,12 +140,6 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" -[[package]] -name = "autocfg" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" - [[package]] name = "backtrace" version = "0.3.76" @@ -170,12 +155,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - [[package]] name = "biome_console" version = "0.5.7" @@ -392,15 +371,6 @@ version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" -[[package]] -name = "block-buffer" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" -dependencies = [ - "hybrid-array", -] - [[package]] name = "bpaf" version = "0.9.26" @@ -432,18 +402,6 @@ dependencies = [ "serde", ] -[[package]] -name = "bumpalo" -version = "3.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - -[[package]] -name = "bytes" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" - [[package]] name = "camino" version = "1.2.3" @@ -466,20 +424,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" -[[package]] -name = "chrono" -version = "0.4.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-link", -] - [[package]] name = "clap" version = "4.6.1" @@ -532,7 +476,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -548,37 +492,7 @@ checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" dependencies = [ "encode_unicode", "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "const-oid" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "cpufeatures" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" -dependencies = [ - "libc", -] - -[[package]] -name = "crc32fast" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", + "windows-sys", ] [[package]] @@ -637,26 +551,6 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" -[[package]] -name = "crypto-common" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" -dependencies = [ - "hybrid-array", -] - -[[package]] -name = "digest" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" -dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", -] - [[package]] name = "displaydoc" version = "0.2.6" @@ -726,7 +620,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -736,7 +630,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" dependencies = [ "cfg-if", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -745,32 +639,12 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" -[[package]] -name = "filetime" -version = "0.2.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" -dependencies = [ - "cfg-if", - "libc", -] - [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" -[[package]] -name = "flate2" -version = "1.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" -dependencies = [ - "crc32fast", - "miniz_oxide", -] - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -788,41 +662,6 @@ dependencies = [ "path-absolutize", ] -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-core", - "futures-task", - "pin-project-lite", - "slab", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - [[package]] name = "getrandom" version = "0.4.3" @@ -880,61 +719,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "http" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "hybrid-array" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" -dependencies = [ - "typenum", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - [[package]] name = "icu_collections" version = "2.2.0" @@ -1080,8 +864,10 @@ checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82" dependencies = [ "console", "once_cell", + "regex", "serde", "similar", + "strip-ansi-escapes", "tempfile", ] @@ -1199,17 +985,6 @@ dependencies = [ "url", ] -[[package]] -name = "js-sys" -version = "0.3.102" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" -dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", -] - [[package]] name = "lazy_static" version = "1.5.0" @@ -1294,7 +1069,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" dependencies = [ "adler2", - "simd-adler32", ] [[package]] @@ -1303,42 +1077,23 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", + "windows-sys", ] [[package]] name = "oak_core" version = "0.1.0" -source = "git+https://github.com/posit-dev/ark?rev=fcb9a40f3e84074298b57b05b4de68e5515c9413#fcb9a40f3e84074298b57b05b4de68e5515c9413" +source = "git+https://github.com/posit-dev/ark?rev=b4cb2e3122e590fa40d99661c2726defb3d42b1d#b4cb2e3122e590fa40d99661c2726defb3d42b1d" dependencies = [ "air_r_syntax", - "biome_rowan", -] - -[[package]] -name = "oak_fs" -version = "0.1.0" -source = "git+https://github.com/posit-dev/ark?rev=fcb9a40f3e84074298b57b05b4de68e5515c9413#fcb9a40f3e84074298b57b05b4de68e5515c9413" -dependencies = [ "anyhow", - "libc", - "log", - "windows-sys 0.61.2", + "biome_rowan", ] [[package]] name = "oak_index_vec" version = "0.1.0" -source = "git+https://github.com/posit-dev/ark?rev=fcb9a40f3e84074298b57b05b4de68e5515c9413#fcb9a40f3e84074298b57b05b4de68e5515c9413" +source = "git+https://github.com/posit-dev/ark?rev=b4cb2e3122e590fa40d99661c2726defb3d42b1d#b4cb2e3122e590fa40d99661c2726defb3d42b1d" dependencies = [ "biome_text_size", "oak_core", @@ -1347,7 +1102,7 @@ dependencies = [ [[package]] name = "oak_package_metadata" version = "0.1.0" -source = "git+https://github.com/posit-dev/ark?rev=fcb9a40f3e84074298b57b05b4de68e5515c9413#fcb9a40f3e84074298b57b05b4de68e5515c9413" +source = "git+https://github.com/posit-dev/ark?rev=b4cb2e3122e590fa40d99661c2726defb3d42b1d#b4cb2e3122e590fa40d99661c2726defb3d42b1d" dependencies = [ "air_r_parser", "air_r_syntax", @@ -1358,20 +1113,10 @@ dependencies = [ "stdext", ] -[[package]] -name = "oak_r_process" -version = "0.1.0" -source = "git+https://github.com/posit-dev/ark?rev=fcb9a40f3e84074298b57b05b4de68e5515c9413#fcb9a40f3e84074298b57b05b4de68e5515c9413" -dependencies = [ - "anyhow", - "log", - "tempfile", -] - [[package]] name = "oak_semantic" version = "0.1.0" -source = "git+https://github.com/posit-dev/ark?rev=fcb9a40f3e84074298b57b05b4de68e5515c9413#fcb9a40f3e84074298b57b05b4de68e5515c9413" +source = "git+https://github.com/posit-dev/ark?rev=b4cb2e3122e590fa40d99661c2726defb3d42b1d#b4cb2e3122e590fa40d99661c2726defb3d42b1d" dependencies = [ "air_r_parser", "air_r_syntax", @@ -1383,34 +1128,12 @@ dependencies = [ "oak_core", "oak_index_vec", "oak_package_metadata", - "oak_sources", "rustc-hash", "smallvec", "stdext", "url", ] -[[package]] -name = "oak_sources" -version = "0.1.0" -source = "git+https://github.com/posit-dev/ark?rev=fcb9a40f3e84074298b57b05b4de68e5515c9413#fcb9a40f3e84074298b57b05b4de68e5515c9413" -dependencies = [ - "anyhow", - "chrono", - "etcetera", - "flate2", - "hex", - "log", - "oak_fs", - "oak_package_metadata", - "oak_r_process", - "serde", - "serde_json", - "sha2", - "tar", - "ureq", -] - [[package]] name = "object" version = "0.37.3" @@ -1585,20 +1308,6 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - [[package]] name = "rustc-demangle" version = "0.1.27" @@ -1621,50 +1330,9 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys", ] -[[package]] -name = "rustls" -version = "0.23.40" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" -dependencies = [ - "log", - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-pki-types" -version = "1.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" -dependencies = [ - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - [[package]] name = "same-file" version = "1.0.6" @@ -1752,7 +1420,6 @@ version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ - "indexmap", "itoa", "memchr", "serde", @@ -1789,17 +1456,6 @@ dependencies = [ "serde", ] -[[package]] -name = "sha2" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - [[package]] name = "sharded-slab" version = "0.1.7" @@ -1815,12 +1471,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" -[[package]] -name = "simd-adler32" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" - [[package]] name = "similar" version = "2.7.0" @@ -1831,12 +1481,6 @@ dependencies = [ "unicode-segmentation", ] -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - [[package]] name = "smallvec" version = "1.15.2" @@ -1861,7 +1505,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "stdext" version = "0.1.0" -source = "git+https://github.com/posit-dev/ark?rev=fcb9a40f3e84074298b57b05b4de68e5515c9413#fcb9a40f3e84074298b57b05b4de68e5515c9413" +source = "git+https://github.com/posit-dev/ark?rev=b4cb2e3122e590fa40d99661c2726defb3d42b1d#b4cb2e3122e590fa40d99661c2726defb3d42b1d" dependencies = [ "anyhow", "log", @@ -1874,16 +1518,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" [[package]] -name = "strsim" -version = "0.11.1" +name = "strip-ansi-escapes" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +checksum = "2a8f8038e7e7969abb3f1b7c2a811225e9296da208539e0f79c5251d6cac0025" +dependencies = [ + "vte", +] [[package]] -name = "subtle" -version = "2.6.1" +name = "strsim" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "syn" @@ -1918,17 +1565,6 @@ dependencies = [ "syn 2.0.118", ] -[[package]] -name = "tar" -version = "0.4.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" -dependencies = [ - "filetime", - "libc", - "xattr", -] - [[package]] name = "tempfile" version = "3.27.0" @@ -1936,10 +1572,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -1958,7 +1594,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ "rustix", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -2126,12 +1762,6 @@ dependencies = [ "tree-sitter-language", ] -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - [[package]] name = "unicode-bom" version = "2.0.3" @@ -2168,41 +1798,6 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "ureq" -version = "3.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" -dependencies = [ - "base64", - "flate2", - "log", - "percent-encoding", - "rustls", - "rustls-pki-types", - "ureq-proto", - "utf8-zero", - "webpki-roots", -] - -[[package]] -name = "ureq-proto" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" -dependencies = [ - "base64", - "http", - "httparse", - "log", -] - [[package]] name = "url" version = "2.5.8" @@ -2216,12 +1811,6 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "utf8-zero" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -2241,64 +1830,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" [[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasm-bindgen" -version = "0.2.125" +name = "vte" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" +checksum = "231fdcd7ef3037e8330d8e17e61011a2c244126acc0a982f4040ac3f9f0bc077" dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.125" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.125" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 2.0.118", - "wasm-bindgen-shared", + "memchr", ] [[package]] -name = "wasm-bindgen-shared" -version = "0.2.125" +name = "walkdir" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" dependencies = [ - "unicode-ident", + "same-file", + "winapi-util", ] [[package]] @@ -2337,57 +1884,13 @@ dependencies = [ "semver", ] -[[package]] -name = "webpki-roots" -version = "1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "winapi-util" version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", + "windows-sys", ] [[package]] @@ -2396,33 +1899,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -2432,70 +1908,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - [[package]] name = "winnow" version = "0.7.15" @@ -2623,16 +2035,6 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" -[[package]] -name = "xattr" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" -dependencies = [ - "libc", - "rustix", -] - [[package]] name = "xtask" version = "0.0.0" @@ -2714,12 +2116,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zeroize" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" - [[package]] name = "zerotrie" version = "0.2.4" diff --git a/Cargo.toml b/Cargo.toml index 8235b3840..98e91951b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,9 +15,9 @@ air_fs = { package = "fs", git = "https://github.com/posit-dev/air", rev = "d265 air_r_parser = { git = "https://github.com/posit-dev/air", rev = "d2659d5b158374bf486b594625ca50abbd0ac879" } air_r_syntax = { git = "https://github.com/posit-dev/air", rev = "d2659d5b158374bf486b594625ca50abbd0ac879" } air_workspace = { package = "workspace", git = "https://github.com/posit-dev/air", rev = "d2659d5b158374bf486b594625ca50abbd0ac879" } -oak_core = { git = "https://github.com/posit-dev/ark", rev = "fcb9a40f3e84074298b57b05b4de68e5515c9413" } -oak_index_vec = { git = "https://github.com/posit-dev/ark", rev = "fcb9a40f3e84074298b57b05b4de68e5515c9413" } -oak_semantic = { git = "https://github.com/posit-dev/ark", rev = "fcb9a40f3e84074298b57b05b4de68e5515c9413" } +oak_core = { git = "https://github.com/posit-dev/ark", rev = "b4cb2e3122e590fa40d99661c2726defb3d42b1d" } +oak_index_vec = { git = "https://github.com/posit-dev/ark", rev = "b4cb2e3122e590fa40d99661c2726defb3d42b1d" } +oak_semantic = { git = "https://github.com/posit-dev/ark", rev = "b4cb2e3122e590fa40d99661c2726defb3d42b1d" } url = "2.5" anyhow = "1.0.94" biome_formatter = { git = "https://github.com/lionel-/biome", rev = "a1296ea6ba363d8b8d8f02181b2a4ce9315c5ef9" } @@ -25,7 +25,7 @@ biome_rowan = { git = "https://github.com/lionel-/biome", rev = "a1296ea6ba363d8 clap = { version = "4.6.0", features = ["derive"] } colored = "3.1.1" ignore = "0.4.23" -insta = { version = "1.47.2", features = ["yaml"] } +insta = { version = "1.47.2", features = ["yaml", "filters"] } etcetera = "0.11.0" jarl-core = { path = "crates/jarl-core" } jarl-lsp = { path = "crates/jarl-lsp" } diff --git a/crates/jarl-semantic/src/lib.rs b/crates/jarl-semantic/src/lib.rs index 25e9cd644..53025a046 100644 --- a/crates/jarl-semantic/src/lib.rs +++ b/crates/jarl-semantic/src/lib.rs @@ -96,7 +96,7 @@ impl<'a> SemanticInfo<'a> { let mut stack = vec![ScopeId::from(0)]; while let Some(s) = stack.pop() { ids.push(s); - stack.extend(self.index.child_scopes(s)); + stack.extend(self.index.child_scope_ids(s)); } ids } @@ -427,7 +427,7 @@ impl<'a> SemanticInfo<'a> { fn precompute_closure_uses(&mut self, scopes: &[ScopeId]) { let mut parent_of: HashMap = HashMap::new(); for &scope in scopes { - for child in self.index.child_scopes(scope) { + for child in self.index.child_scope_ids(scope) { parent_of.insert(child, scope); } } @@ -567,7 +567,7 @@ impl<'a> SemanticInfo<'a> { let mut out = vec![scope_id]; let mut stack = vec![scope_id]; while let Some(s) = stack.pop() { - for child in self.index.child_scopes(s) { + for child in self.index.child_scope_ids(s) { out.push(child); stack.push(child); } From 768748091fd5a8533ba1e765b0f9aa61efb83e3b Mon Sep 17 00:00:00 2001 From: etiennebacher Date: Fri, 19 Jun 2026 15:51:11 +0100 Subject: [PATCH 12/39] use oak fork [skip ci] --- Cargo.lock | 32 ++++++++++++++++---------------- Cargo.toml | 14 +++++++------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6513333ee..e1775ee80 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,7 +29,7 @@ dependencies = [ [[package]] name = "air_r_factory" version = "0.0.0" -source = "git+https://github.com/posit-dev/air?rev=d2659d5b158374bf486b594625ca50abbd0ac879#d2659d5b158374bf486b594625ca50abbd0ac879" +source = "git+https://github.com/posit-dev/air?rev=0eadd6938d0316b1fc885c91e7af55d7c8de8530#0eadd6938d0316b1fc885c91e7af55d7c8de8530" dependencies = [ "air_r_syntax", "biome_rowan", @@ -38,7 +38,7 @@ dependencies = [ [[package]] name = "air_r_formatter" version = "0.0.0" -source = "git+https://github.com/posit-dev/air?rev=d2659d5b158374bf486b594625ca50abbd0ac879#d2659d5b158374bf486b594625ca50abbd0ac879" +source = "git+https://github.com/posit-dev/air?rev=0eadd6938d0316b1fc885c91e7af55d7c8de8530#0eadd6938d0316b1fc885c91e7af55d7c8de8530" dependencies = [ "air_r_syntax", "biome_formatter", @@ -52,7 +52,7 @@ dependencies = [ [[package]] name = "air_r_parser" version = "0.0.0" -source = "git+https://github.com/posit-dev/air?rev=d2659d5b158374bf486b594625ca50abbd0ac879#d2659d5b158374bf486b594625ca50abbd0ac879" +source = "git+https://github.com/posit-dev/air?rev=0eadd6938d0316b1fc885c91e7af55d7c8de8530#0eadd6938d0316b1fc885c91e7af55d7c8de8530" dependencies = [ "air_r_factory", "air_r_syntax", @@ -68,7 +68,7 @@ dependencies = [ [[package]] name = "air_r_syntax" version = "0.0.0" -source = "git+https://github.com/posit-dev/air?rev=d2659d5b158374bf486b594625ca50abbd0ac879#d2659d5b158374bf486b594625ca50abbd0ac879" +source = "git+https://github.com/posit-dev/air?rev=0eadd6938d0316b1fc885c91e7af55d7c8de8530#0eadd6938d0316b1fc885c91e7af55d7c8de8530" dependencies = [ "biome_rowan", "serde", @@ -482,7 +482,7 @@ dependencies = [ [[package]] name = "comments" version = "0.0.0" -source = "git+https://github.com/posit-dev/air?rev=d2659d5b158374bf486b594625ca50abbd0ac879#d2659d5b158374bf486b594625ca50abbd0ac879" +source = "git+https://github.com/posit-dev/air?rev=0eadd6938d0316b1fc885c91e7af55d7c8de8530#0eadd6938d0316b1fc885c91e7af55d7c8de8530" [[package]] name = "console" @@ -657,7 +657,7 @@ dependencies = [ [[package]] name = "fs" version = "0.0.0" -source = "git+https://github.com/posit-dev/air?rev=d2659d5b158374bf486b594625ca50abbd0ac879#d2659d5b158374bf486b594625ca50abbd0ac879" +source = "git+https://github.com/posit-dev/air?rev=0eadd6938d0316b1fc885c91e7af55d7c8de8530#0eadd6938d0316b1fc885c91e7af55d7c8de8530" dependencies = [ "path-absolutize", ] @@ -1006,7 +1006,7 @@ checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "line_ending" version = "0.0.0" -source = "git+https://github.com/posit-dev/air?rev=d2659d5b158374bf486b594625ca50abbd0ac879#d2659d5b158374bf486b594625ca50abbd0ac879" +source = "git+https://github.com/posit-dev/air?rev=0eadd6938d0316b1fc885c91e7af55d7c8de8530#0eadd6938d0316b1fc885c91e7af55d7c8de8530" dependencies = [ "memchr", "settings", @@ -1083,7 +1083,7 @@ dependencies = [ [[package]] name = "oak_core" version = "0.1.0" -source = "git+https://github.com/posit-dev/ark?rev=b4cb2e3122e590fa40d99661c2726defb3d42b1d#b4cb2e3122e590fa40d99661c2726defb3d42b1d" +source = "git+https://github.com/etiennebacher/ark?branch=bump-air-commit#f8fb4d37b48f77a6a8199efc4c0cdfd027ed686c" dependencies = [ "air_r_syntax", "anyhow", @@ -1093,7 +1093,7 @@ dependencies = [ [[package]] name = "oak_index_vec" version = "0.1.0" -source = "git+https://github.com/posit-dev/ark?rev=b4cb2e3122e590fa40d99661c2726defb3d42b1d#b4cb2e3122e590fa40d99661c2726defb3d42b1d" +source = "git+https://github.com/etiennebacher/ark?branch=bump-air-commit#f8fb4d37b48f77a6a8199efc4c0cdfd027ed686c" dependencies = [ "biome_text_size", "oak_core", @@ -1102,7 +1102,7 @@ dependencies = [ [[package]] name = "oak_package_metadata" version = "0.1.0" -source = "git+https://github.com/posit-dev/ark?rev=b4cb2e3122e590fa40d99661c2726defb3d42b1d#b4cb2e3122e590fa40d99661c2726defb3d42b1d" +source = "git+https://github.com/etiennebacher/ark?branch=bump-air-commit#f8fb4d37b48f77a6a8199efc4c0cdfd027ed686c" dependencies = [ "air_r_parser", "air_r_syntax", @@ -1116,7 +1116,7 @@ dependencies = [ [[package]] name = "oak_semantic" version = "0.1.0" -source = "git+https://github.com/posit-dev/ark?rev=b4cb2e3122e590fa40d99661c2726defb3d42b1d#b4cb2e3122e590fa40d99661c2726defb3d42b1d" +source = "git+https://github.com/etiennebacher/ark?branch=bump-air-commit#f8fb4d37b48f77a6a8199efc4c0cdfd027ed686c" dependencies = [ "air_r_parser", "air_r_syntax", @@ -1450,7 +1450,7 @@ dependencies = [ [[package]] name = "settings" version = "0.0.0" -source = "git+https://github.com/posit-dev/air?rev=d2659d5b158374bf486b594625ca50abbd0ac879#d2659d5b158374bf486b594625ca50abbd0ac879" +source = "git+https://github.com/posit-dev/air?rev=0eadd6938d0316b1fc885c91e7af55d7c8de8530#0eadd6938d0316b1fc885c91e7af55d7c8de8530" dependencies = [ "biome_formatter", "serde", @@ -1505,7 +1505,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "stdext" version = "0.1.0" -source = "git+https://github.com/posit-dev/ark?rev=b4cb2e3122e590fa40d99661c2726defb3d42b1d#b4cb2e3122e590fa40d99661c2726defb3d42b1d" +source = "git+https://github.com/etiennebacher/ark?branch=bump-air-commit#f8fb4d37b48f77a6a8199efc4c0cdfd027ed686c" dependencies = [ "anyhow", "log", @@ -1755,8 +1755,8 @@ checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" [[package]] name = "tree-sitter-r" -version = "1.1.0" -source = "git+https://github.com/r-lib/tree-sitter-r?rev=daa26a2ff0d9546e9125c7d8bcec046027f02070#daa26a2ff0d9546e9125c7d8bcec046027f02070" +version = "1.2.0" +source = "git+https://github.com/r-lib/tree-sitter-r?rev=37a6b1b5c8cc2426fd6ec16ca818cc98597d755b#37a6b1b5c8cc2426fd6ec16ca818cc98597d755b" dependencies = [ "cc", "tree-sitter-language", @@ -2012,7 +2012,7 @@ dependencies = [ [[package]] name = "workspace" version = "0.1.0" -source = "git+https://github.com/posit-dev/air?rev=d2659d5b158374bf486b594625ca50abbd0ac879#d2659d5b158374bf486b594625ca50abbd0ac879" +source = "git+https://github.com/posit-dev/air?rev=0eadd6938d0316b1fc885c91e7af55d7c8de8530#0eadd6938d0316b1fc885c91e7af55d7c8de8530" dependencies = [ "air_r_formatter", "air_r_parser", diff --git a/Cargo.toml b/Cargo.toml index 98e91951b..5b38fae26 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,13 +11,13 @@ repository = "https://github.com/etiennebacher/jarl/" readme = "README.md" [workspace.dependencies] -air_fs = { package = "fs", git = "https://github.com/posit-dev/air", rev = "d2659d5b158374bf486b594625ca50abbd0ac879" } -air_r_parser = { git = "https://github.com/posit-dev/air", rev = "d2659d5b158374bf486b594625ca50abbd0ac879" } -air_r_syntax = { git = "https://github.com/posit-dev/air", rev = "d2659d5b158374bf486b594625ca50abbd0ac879" } -air_workspace = { package = "workspace", git = "https://github.com/posit-dev/air", rev = "d2659d5b158374bf486b594625ca50abbd0ac879" } -oak_core = { git = "https://github.com/posit-dev/ark", rev = "b4cb2e3122e590fa40d99661c2726defb3d42b1d" } -oak_index_vec = { git = "https://github.com/posit-dev/ark", rev = "b4cb2e3122e590fa40d99661c2726defb3d42b1d" } -oak_semantic = { git = "https://github.com/posit-dev/ark", rev = "b4cb2e3122e590fa40d99661c2726defb3d42b1d" } +air_fs = { package = "fs", git = "https://github.com/posit-dev/air", rev = "0eadd6938d0316b1fc885c91e7af55d7c8de8530" } +air_r_parser = { git = "https://github.com/posit-dev/air", rev = "0eadd6938d0316b1fc885c91e7af55d7c8de8530" } +air_r_syntax = { git = "https://github.com/posit-dev/air", rev = "0eadd6938d0316b1fc885c91e7af55d7c8de8530" } +air_workspace = { package = "workspace", git = "https://github.com/posit-dev/air", rev = "0eadd6938d0316b1fc885c91e7af55d7c8de8530" } +oak_core = { git = "https://github.com/etiennebacher/ark", branch = "bump-air-commit" } +oak_index_vec = { git = "https://github.com/etiennebacher/ark", branch = "bump-air-commit" } +oak_semantic = { git = "https://github.com/etiennebacher/ark", branch = "bump-air-commit" } url = "2.5" anyhow = "1.0.94" biome_formatter = { git = "https://github.com/lionel-/biome", rev = "a1296ea6ba363d8b8d8f02181b2a4ce9315c5ef9" } From 757342bc6b391d8cecff9605f327f2408ac83a24 Mon Sep 17 00:00:00 2001 From: etiennebacher Date: Fri, 19 Jun 2026 16:33:39 +0100 Subject: [PATCH 13/39] use more of oak's api --- .../src/lints/base/unused_object/mod.rs | 25 +- .../lints/base/unused_object/unused_object.rs | 2 +- crates/jarl-semantic/src/lib.rs | 260 ++++-------------- 3 files changed, 67 insertions(+), 220 deletions(-) diff --git a/crates/jarl-core/src/lints/base/unused_object/mod.rs b/crates/jarl-core/src/lints/base/unused_object/mod.rs index cdf3307b6..cde9d0239 100644 --- a/crates/jarl-core/src/lints/base/unused_object/mod.rs +++ b/crates/jarl-core/src/lints/base/unused_object/mod.rs @@ -297,23 +297,20 @@ mod tests { } #[test] - fn test_lint_closure_only_called_after_redefinition() { - // `x <- 1` is unused because `f()` is only called after `x <- 2`. - assert_snapshot!( - snapshot_lint(" + fn test_no_lint_closure_only_called_after_redefinition() { + // `x <- 1` is technically dead here (`f()` only runs after `x <- 2`), + // but oak's enclosing snapshot captures the union of `x`'s definitions + // for the closure, so `x <- 1` counts as used. We accept this + // conservative answer (no false positives) rather than re-deriving + // call-site-sensitive capture ourselves. + expect_no_lint( + " x <- 1 f <- function() x x <- 2 -f()"), - @" - warning: unused_object - --> :2:1 - | - 2 | x <- 1 - | - Object `x` is defined but never used. - | - Found 1 error. - " +f()", + "unused_object", + None, ); } diff --git a/crates/jarl-core/src/lints/base/unused_object/unused_object.rs b/crates/jarl-core/src/lints/base/unused_object/unused_object.rs index 51de61819..321c09844 100644 --- a/crates/jarl-core/src/lints/base/unused_object/unused_object.rs +++ b/crates/jarl-core/src/lints/base/unused_object/unused_object.rs @@ -177,7 +177,7 @@ fn collect_assignment_pipe_diagnostics( .iter() .any(|(_, u)| u.symbol() == sym && u.range().start() >= expr_end) }); - let closure_use = symbol.is_some_and(|sym| info.closure_escaped(scope_id, sym)); + let closure_use = info.is_used_in_nested_scope(scope_id, &name); let exported = scope_id == ScopeId::from(0) && exports.contains(&name); if !later_use && !closure_use && !exported { diff --git a/crates/jarl-semantic/src/lib.rs b/crates/jarl-semantic/src/lib.rs index 53025a046..2a737f8bd 100644 --- a/crates/jarl-semantic/src/lib.rs +++ b/crates/jarl-semantic/src/lib.rs @@ -10,17 +10,17 @@ //! `info.is_definition_used(scope, def_id, def)` rather than walking the //! semantic index themselves. -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; use air_r_parser::RParserOptions; use air_r_syntax::{ AnyRArgumentName, AnyRExpression, RArgument, RArgumentList, RBinaryExpression, RCall, - RExtractExpression, RNamespaceExpression, RSyntaxKind, RSyntaxNode, + RExtractExpression, RNamespaceExpression, RStringValue, RSyntaxKind, RSyntaxNode, }; -use biome_rowan::{AstNode, AstSeparatedList, SyntaxNodeCast, TextRange, TextSize}; -use oak_core::syntax_ext::RIdentifierExt; +use biome_rowan::{AstNode, AstSeparatedList, SyntaxNodeCast, TextRange}; +use oak_core::syntax_ext::{RIdentifierExt, RStringValueExt}; use oak_semantic::DefinitionId; -use oak_semantic::semantic_index::{Definition, DefinitionKind, ScopeId, SemanticIndex, SymbolId}; +use oak_semantic::semantic_index::{Definition, DefinitionKind, ScopeId, SemanticIndex}; /// Per-file semantic info derived from oak's [`SemanticIndex`] plus AST /// passes over the syntax tree. Computed once per file; consumed by lints. @@ -44,20 +44,16 @@ pub struct SemanticInfo<'a> { local_body_ranges: Vec, /// Ranges of formula RHSes (`~ rhs`). formula_ranges: Vec, - /// Definitions reachable through call-site analysis of nested closures. - closure_used_defs: HashSet<(ScopeId, DefinitionId)>, - /// Symbols whose nested closure escapes (returned/anonymous/passed as - /// argument); all enclosing-scope defs of that symbol are conservatively - /// considered used. - closure_escaped_symbols: HashSet<(ScopeId, SymbolId)>, - /// Identifier ranges that appear as the callee of an `R_CALL`. - callee_ranges: Vec, + /// Definitions reached by some non-NSE use anywhere in the file. Computed + /// from oak's `reaching_definitions`, which resolves both local uses and + /// free-variable uses in nested closures (via enclosing snapshots). + reaching_used: HashSet<(ScopeId, DefinitionId)>, } impl<'a> SemanticInfo<'a> { - /// Build the info table. Runs both the AST pass (collecting synthetic - /// uses, NSE ranges, formula ranges, local body ranges, callee ranges) - /// and the closure call-site analysis. + /// Build the info table. Runs the AST pass (collecting synthetic uses, + /// NSE ranges, formula ranges, local body ranges) and then the + /// reaching-use precomputation over oak's use-def maps. pub fn build( root: &RSyntaxNode, expressions: &[RSyntaxNode], @@ -72,13 +68,11 @@ impl<'a> SemanticInfo<'a> { nse_ranges: Vec::new(), local_body_ranges: Vec::new(), formula_ranges: Vec::new(), - closure_used_defs: HashSet::new(), - closure_escaped_symbols: HashSet::new(), - callee_ranges: Vec::new(), + reaching_used: HashSet::new(), }; this.collect_ast_passes(expressions); let scopes = this.scope_ids(); - this.precompute_closure_uses(&scopes); + this.precompute_reaching_uses(&scopes); this } @@ -92,20 +86,14 @@ impl<'a> SemanticInfo<'a> { /// Walk all scopes (root + descendants) in arbitrary order. pub fn scope_ids(&self) -> Vec { - let mut ids = Vec::new(); - let mut stack = vec![ScopeId::from(0)]; - while let Some(s) = stack.pop() { - ids.push(s); - stack.extend(self.index.child_scope_ids(s)); - } - ids + self.index.scope_ids().collect() } // ── High-level queries ──────────────────────────────────────────── /// True if any of the supported "is used" conditions hold for this - /// definition: synthetic AST-derived use, in-scope reaching use, - /// closure-escape, or `local({…})` body shortcut. + /// definition: synthetic AST-derived use, a reaching use (local or via a + /// nested closure), or the `local({…})` body shortcut. pub fn is_definition_used( &self, scope_id: ScopeId, @@ -116,10 +104,7 @@ impl<'a> SemanticInfo<'a> { if self.synthetic_used_names.contains(symbol_name) { return true; } - if self.is_reached_by_use_in_scope(scope_id, def_id, def.symbol()) { - return true; - } - if self.is_used_via_closure(scope_id, def_id, def.symbol()) { + if self.reaching_used.contains(&(scope_id, def_id)) { return true; } if self.is_used_inside_local_body(scope_id, def) { @@ -142,14 +127,28 @@ impl<'a> SemanticInfo<'a> { self.synthetic_used_names.contains(name) } - pub fn closure_escaped(&self, scope: ScopeId, symbol: SymbolId) -> bool { - self.closure_escaped_symbols.contains(&(scope, symbol)) - } - - pub fn is_callee(&self, range: TextRange) -> bool { - self.callee_ranges - .iter() - .any(|r| r.contains_range(range) || *r == range) + /// True if `name` is referenced as a use in any scope nested under + /// `scope_id`. Keeps a binding alive when a nested closure captures it. + /// Needed for `%<>%`, which oak doesn't model as a definition, so its + /// reaching-use set can't answer the closure-capture question directly. + pub fn is_used_in_nested_scope(&self, scope_id: ScopeId, name: &str) -> bool { + for descendant in self.scope_descendants(scope_id) { + if descendant == scope_id { + continue; + } + let Some(symbol) = self.index.symbols(descendant).id(name) else { + continue; + }; + if self + .index + .uses(descendant) + .iter() + .any(|(_, u)| u.symbol() == symbol) + { + return true; + } + } + false } // ── Internal: AST pass ──────────────────────────────────────────── @@ -306,10 +305,6 @@ impl<'a> SemanticInfo<'a> { } fn visit_call(&mut self, call: &RCall) { - if let Ok(func) = call.function() { - self.callee_ranges.push(func.syntax().text_trimmed_range()); - } - let Some(name) = call_name(call) else { return; }; @@ -389,155 +384,25 @@ impl<'a> SemanticInfo<'a> { // ── Internal: reach / closure analysis ──────────────────────────── - fn is_reached_by_use_in_scope( - &self, - scope_id: ScopeId, - def_id: DefinitionId, - symbol: SymbolId, - ) -> bool { - let use_def = self.index.use_def_map(scope_id); - let uses = self.index.uses(scope_id); - for (use_id, u) in uses.iter() { - if u.symbol() != symbol { - continue; - } - if in_any_range(u.range(), &self.nse_ranges) { - continue; - } - let bindings = use_def.bindings_at_use(use_id); - if bindings.contains_definition(def_id) { - return true; - } - } - false - } - - fn is_used_via_closure( - &self, - scope_id: ScopeId, - def_id: DefinitionId, - symbol: SymbolId, - ) -> bool { - if self.closure_escaped_symbols.contains(&(scope_id, symbol)) { - return true; - } - self.closure_used_defs.contains(&(scope_id, def_id)) - } - - fn precompute_closure_uses(&mut self, scopes: &[ScopeId]) { - let mut parent_of: HashMap = HashMap::new(); - for &scope in scopes { - for child in self.index.child_scope_ids(scope) { - parent_of.insert(child, scope); - } - } - for &child in scopes { - let Some(&parent) = parent_of.get(&child) else { - continue; - }; - self.classify_closure(parent, child); - } - } - - fn classify_closure(&mut self, parent: ScopeId, child: ScopeId) { - let child_range = self.index.scope(child).range(); - - let mut binding_name: Option = None; - for (_, def) in self.index.definitions(parent).iter() { - if let DefinitionKind::Assignment(ptr) = def.kind() - && ptr - .syntax_node_ptr() - .text_range() - .contains_range(child_range) - && assignment_rhs_is_function_def(&ptr.to_node(&self.root)) - { - let name = self - .index - .symbols(parent) - .symbol(def.symbol()) - .name() - .to_string(); - binding_name = Some(name); - break; - } - } - - let Some(name) = binding_name else { - self.escape_free_vars(parent, child); - return; - }; - - let Some(parent_symbol) = self.index.symbols(parent).id(&name) else { - self.escape_free_vars(parent, child); - return; - }; - - let mut call_offsets: Vec = Vec::new(); - let mut escaped = false; - for (_, u) in self.index.uses(parent).iter() { - if u.symbol() != parent_symbol { - continue; - } - if self.is_callee(u.range()) { - call_offsets.push(u.range().end()); - } else { - escaped = true; - break; - } - } - - if escaped || call_offsets.is_empty() { - self.escape_free_vars(parent, child); - return; - } - - let free_vars = self.free_variables(child, parent); - for parent_sym in free_vars { - let parent_defs: Vec<(DefinitionId, TextSize)> = self - .index - .definitions(parent) - .iter() - .filter(|(_, d)| d.symbol() == parent_sym) - .map(|(id, d)| (id, d.range().end())) - .collect(); - for &offset in &call_offsets { - if let Some((def_id, _)) = parent_defs - .iter() - .filter(|(_, def_end)| *def_end <= offset) - .max_by_key(|(_, def_end)| *def_end) - { - self.closure_used_defs.insert((parent, *def_id)); - } - } - } - } - - fn escape_free_vars(&mut self, parent: ScopeId, child: ScopeId) { - for parent_sym in self.free_variables(child, parent) { - self.closure_escaped_symbols.insert((parent, parent_sym)); - } - } - - fn free_variables(&self, child: ScopeId, parent: ScopeId) -> Vec { - let mut out = Vec::new(); - let mut seen: HashSet = HashSet::new(); - for descendant in self.scope_descendants(child) { - for (_, u) in self.index.uses(descendant).iter() { - let name = self - .index - .symbols(descendant) - .symbol(u.symbol()) - .name() - .to_string(); - if !seen.insert(name.clone()) { + /// Collect every definition reached by a non-NSE use, in every scope. + /// + /// `reaching_definitions` returns both local reaching definitions and, for + /// a free variable in a nested closure, the enclosing-scope definitions + /// captured by oak's enclosing snapshots. So a single pass over all uses + /// covers in-scope reads and closure captures alike. Uses sitting inside an + /// NSE argument (`quote(x)`, …) are skipped: they don't consume a binding. + fn precompute_reaching_uses(&mut self, scopes: &[ScopeId]) { + let index = self.index; + for &scope_id in scopes { + for (use_id, u) in index.uses(scope_id).iter() { + if in_any_range(u.range(), &self.nse_ranges) { continue; } - if let Some(parent_sym) = self.index.symbols(parent).id(&name) { - out.push(parent_sym); + for (def_scope, def_id) in index.reaching_definitions(scope_id, use_id) { + self.reaching_used.insert((def_scope, def_id)); } } } - out } fn is_used_inside_local_body(&self, scope_id: ScopeId, def: &Definition) -> bool { @@ -714,22 +579,7 @@ fn argument_name(arg: &RArgument) -> Option { } fn string_literal_value(node: &RSyntaxNode) -> Option { - if node.kind() != RSyntaxKind::R_STRING_VALUE { - return None; - } - let token = node.first_token()?; - let text = token.text_trimmed(); - let bytes = text.as_bytes(); - if bytes.len() < 2 { - return None; - } - let first = bytes[0]; - let last = *bytes.last().unwrap(); - if (first == b'"' || first == b'\'') && first == last { - Some(text[1..text.len() - 1].to_string()) - } else { - None - } + node.clone().cast::()?.string_text() } /// Resolve a `source("path")` argument against the currently-analyzed file. From e420b738f09ca37999fdadcdea52acf33f57e997 Mon Sep 17 00:00:00 2001 From: etiennebacher Date: Fri, 19 Jun 2026 16:44:16 +0100 Subject: [PATCH 14/39] use Oak's DESCRIPTION parser --- Cargo.lock | 1 + Cargo.toml | 1 + crates/jarl-core/Cargo.toml | 1 + crates/jarl-core/src/description.rs | 58 +++-------------------------- 4 files changed, 9 insertions(+), 52 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e1775ee80..b961e7b21 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -939,6 +939,7 @@ dependencies = [ "jarl-semantic", "oak_core", "oak_index_vec", + "oak_package_metadata", "oak_semantic", "path-absolutize", "rayon", diff --git a/Cargo.toml b/Cargo.toml index 5b38fae26..c183ced91 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ air_r_syntax = { git = "https://github.com/posit-dev/air", rev = "0eadd6938d0316 air_workspace = { package = "workspace", git = "https://github.com/posit-dev/air", rev = "0eadd6938d0316b1fc885c91e7af55d7c8de8530" } oak_core = { git = "https://github.com/etiennebacher/ark", branch = "bump-air-commit" } oak_index_vec = { git = "https://github.com/etiennebacher/ark", branch = "bump-air-commit" } +oak_package_metadata = { git = "https://github.com/etiennebacher/ark", branch = "bump-air-commit" } oak_semantic = { git = "https://github.com/etiennebacher/ark", branch = "bump-air-commit" } url = "2.5" anyhow = "1.0.94" diff --git a/crates/jarl-core/Cargo.toml b/crates/jarl-core/Cargo.toml index 387b3f891..f00a29b30 100644 --- a/crates/jarl-core/Cargo.toml +++ b/crates/jarl-core/Cargo.toml @@ -26,6 +26,7 @@ biome_formatter.workspace = true # Semantic analysis (per-file scopes, definitions, uses, use-def map) oak_core.workspace = true oak_index_vec.workspace = true +oak_package_metadata.workspace = true oak_semantic.workspace = true url.workspace = true diff --git a/crates/jarl-core/src/description.rs b/crates/jarl-core/src/description.rs index 79a874bf9..91ec924c4 100644 --- a/crates/jarl-core/src/description.rs +++ b/crates/jarl-core/src/description.rs @@ -1,12 +1,10 @@ // -// Adapted from Ark -// https://github.com/posit-dev/ark/blob/main/crates/ark/src/lsp/inputs/package_description.rs -// 7f9ea95d367712eb40b1669cf317c7a8a71e779b -// -// MIT License - Posit PBC +// DCF field access delegates to `oak_package_metadata::dcf::Dcf`; the +// R-specific extraction (package dependency lists, R version constraints +// from `Depends`) lives here. use anyhow; -use std::collections::HashMap; +use oak_package_metadata::dcf::Dcf; /// Simple parser for R version requirements from DESCRIPTION files pub struct Description; @@ -19,7 +17,7 @@ impl Description { /// /// Returns package names (excluding `R` itself) in the order they appear. pub fn get_package_deps(contents: &str, what: &[&str]) -> Vec { - let parsed = parse_dcf(contents); + let parsed = Dcf::parse(contents); let mut packages = Vec::new(); for field_name in what { @@ -45,7 +43,7 @@ impl Description { /// - "Depends: R" -> [] /// - No Depends field -> [] pub fn get_depend_r_version(contents: &str) -> anyhow::Result> { - let fields = parse_dcf(contents); + let fields = Dcf::parse(contents); let r_versions = fields .get("Depends") @@ -81,50 +79,6 @@ fn extract_version_from_dependency(dep: &str) -> Option { unreachable!("DESCRIPTION cannot have 'R' without version in Depends field.") } -/// Parse a DCF (Debian Control File) format string into a key-value map -/// Minimal implementation focused on extracting the Depends field -fn parse_dcf(input: &str) -> HashMap { - let mut fields = HashMap::new(); - let mut current_key: Option = None; - let mut current_value = String::new(); - - for line in input.lines() { - // Indented line: continuation of previous field - if line.starts_with(char::is_whitespace) { - current_value.push_str(line.trim()); - current_value.push(' '); - continue; - } - - // New field: contains a colon and doesn't start with whitespace - if !line.is_empty() && line.contains(':') { - // Save previous field if exists - if let Some(key) = current_key.take() { - fields.insert(key, current_value.trim().to_string()); - } - - // Parse new field - let colon_pos = line.find(':').unwrap(); - let key = line[..colon_pos].trim().to_string(); - let value = line[colon_pos + 1..].trim(); - - current_key = Some(key); - current_value = String::from(value); - - if !current_value.is_empty() { - current_value.push(' '); - } - } - } - - // Save the last field - if let Some(key) = current_key { - fields.insert(key, current_value.trim().to_string()); - } - - fields -} - #[cfg(test)] mod tests { use super::*; From d943c95bde9f6a0982978d97ff5eff4ceee538b5 Mon Sep 17 00:00:00 2001 From: etiennebacher Date: Fri, 19 Jun 2026 17:57:09 +0100 Subject: [PATCH 15/39] init --- Cargo.lock | 331 +++++++++++++++++++++++++++++++++- Cargo.toml | 5 + crates/jarl-core/Cargo.toml | 5 + crates/jarl-core/src/check.rs | 9 + crates/jarl-core/src/db.rs | 88 +++++++++ crates/jarl-core/src/lib.rs | 1 + 6 files changed, 434 insertions(+), 5 deletions(-) create mode 100644 crates/jarl-core/src/db.rs diff --git a/Cargo.lock b/Cargo.lock index b961e7b21..56793cf96 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,19 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aether_path" +version = "0.1.0" +source = "git+https://github.com/etiennebacher/ark?branch=bump-air-commit#4f667d4cc8eb762468038144dc58f9e37a0484f9" +dependencies = [ + "anyhow", + "camino", + "log", + "percent-encoding", + "stdext", + "url", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -74,6 +87,12 @@ dependencies = [ "serde", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "annotate-snippets" version = "0.11.5" @@ -140,6 +159,12 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + [[package]] name = "backtrace" version = "0.3.76" @@ -292,6 +317,15 @@ dependencies = [ "serde", ] +[[package]] +name = "biome_line_index" +version = "0.1.0" +source = "git+https://github.com/lionel-/biome?rev=a1296ea6ba363d8b8d8f02181b2a4ce9315c5ef9#a1296ea6ba363d8b8d8f02181b2a4ce9315c5ef9" +dependencies = [ + "biome_text_size", + "rustc-hash", +] + [[package]] name = "biome_markup" version = "0.5.7" @@ -371,6 +405,12 @@ version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +[[package]] +name = "boxcar" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f64beae40a84da1b4b26ff2761a5b895c12adc41dc25aaee1c4f2bbfe97a6e" + [[package]] name = "bpaf" version = "0.9.26" @@ -408,6 +448,15 @@ version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ce8d3bd5823c7504d3f579f13e7b2f3da252fcb938c594d5680ee508bf846f" +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.64" @@ -484,6 +533,20 @@ name = "comments" version = "0.0.0" source = "git+https://github.com/posit-dev/air?rev=0eadd6938d0316b1fc885c91e7af55d7c8de8530#0eadd6938d0316b1fc885c91e7af55d7c8de8530" +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "console" version = "0.16.3" @@ -639,12 +702,34 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -697,12 +782,29 @@ name = "hashbrown" version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] [[package]] name = "heck" @@ -871,6 +973,24 @@ dependencies = [ "tempfile", ] +[[package]] +name = "intrusive-collections" +version = "0.9.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "189d0897e4cbe8c75efedf3502c18c887b05046e59d28404d4d8e46cbc4d1e86" +dependencies = [ + "memoffset", +] + +[[package]] +name = "inventory" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +dependencies = [ + "rustversion", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -926,6 +1046,7 @@ dependencies = [ name = "jarl-core" version = "0.6.0-alpha.2" dependencies = [ + "aether_path", "air_r_parser", "air_r_syntax", "annotate-snippets", @@ -938,13 +1059,17 @@ dependencies = [ "insta", "jarl-semantic", "oak_core", + "oak_db", + "oak_ide", "oak_index_vec", "oak_package_metadata", + "oak_scan", "oak_semantic", "path-absolutize", "rayon", "regex", "rustc-hash", + "salsa", "schemars", "serde", "strsim", @@ -1025,6 +1150,15 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.32" @@ -1063,6 +1197,15 @@ version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -1084,17 +1227,55 @@ dependencies = [ [[package]] name = "oak_core" version = "0.1.0" -source = "git+https://github.com/etiennebacher/ark?branch=bump-air-commit#f8fb4d37b48f77a6a8199efc4c0cdfd027ed686c" +source = "git+https://github.com/etiennebacher/ark?branch=bump-air-commit#4f667d4cc8eb762468038144dc58f9e37a0484f9" dependencies = [ "air_r_syntax", "anyhow", "biome_rowan", ] +[[package]] +name = "oak_db" +version = "0.1.0" +source = "git+https://github.com/etiennebacher/ark?branch=bump-air-commit#4f667d4cc8eb762468038144dc58f9e37a0484f9" +dependencies = [ + "aether_path", + "air_r_parser", + "air_r_syntax", + "biome_line_index", + "biome_rowan", + "camino", + "compact_str", + "filetime", + "log", + "oak_core", + "oak_package_metadata", + "oak_semantic", + "rustc-hash", + "salsa", + "salsa-macros", + "stdext", + "url", +] + +[[package]] +name = "oak_ide" +version = "0.1.0" +source = "git+https://github.com/etiennebacher/ark?branch=bump-air-commit#4f667d4cc8eb762468038144dc58f9e37a0484f9" +dependencies = [ + "air_r_syntax", + "anyhow", + "biome_rowan", + "oak_core", + "oak_db", + "oak_semantic", + "url", +] + [[package]] name = "oak_index_vec" version = "0.1.0" -source = "git+https://github.com/etiennebacher/ark?branch=bump-air-commit#f8fb4d37b48f77a6a8199efc4c0cdfd027ed686c" +source = "git+https://github.com/etiennebacher/ark?branch=bump-air-commit#4f667d4cc8eb762468038144dc58f9e37a0484f9" dependencies = [ "biome_text_size", "oak_core", @@ -1103,7 +1284,7 @@ dependencies = [ [[package]] name = "oak_package_metadata" version = "0.1.0" -source = "git+https://github.com/etiennebacher/ark?branch=bump-air-commit#f8fb4d37b48f77a6a8199efc4c0cdfd027ed686c" +source = "git+https://github.com/etiennebacher/ark?branch=bump-air-commit#4f667d4cc8eb762468038144dc58f9e37a0484f9" dependencies = [ "air_r_parser", "air_r_syntax", @@ -1114,10 +1295,30 @@ dependencies = [ "stdext", ] +[[package]] +name = "oak_scan" +version = "0.1.0" +source = "git+https://github.com/etiennebacher/ark?branch=bump-air-commit#4f667d4cc8eb762468038144dc58f9e37a0484f9" +dependencies = [ + "aether_path", + "camino", + "filetime", + "ignore", + "log", + "oak_core", + "oak_db", + "oak_package_metadata", + "rustc-hash", + "salsa", + "stdext", + "url", + "walkdir", +] + [[package]] name = "oak_semantic" version = "0.1.0" -source = "git+https://github.com/etiennebacher/ark?branch=bump-air-commit#f8fb4d37b48f77a6a8199efc4c0cdfd027ed686c" +source = "git+https://github.com/etiennebacher/ark?branch=bump-air-commit#4f667d4cc8eb762468038144dc58f9e37a0484f9" dependencies = [ "air_r_parser", "air_r_syntax", @@ -1130,6 +1331,7 @@ dependencies = [ "oak_index_vec", "oak_package_metadata", "rustc-hash", + "salsa", "smallvec", "stdext", "url", @@ -1156,6 +1358,29 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "path-absolutize" version = "3.1.1" @@ -1186,6 +1411,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "potential_utf" version = "0.1.5" @@ -1260,6 +1491,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.0", +] + [[package]] name = "ref-cast" version = "1.0.25" @@ -1334,6 +1574,63 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "salsa" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4612ff789805e65c87e9b38cb749a293212a615af065bed8a2001086801498c3" +dependencies = [ + "boxcar", + "compact_str", + "crossbeam-queue", + "crossbeam-utils", + "hashbrown 0.17.1", + "hashlink", + "indexmap", + "intrusive-collections", + "inventory", + "parking_lot", + "portable-atomic", + "rayon", + "rustc-hash", + "salsa-macro-rules", + "salsa-macros", + "smallvec", + "thin-vec", + "tracing", + "typeid", +] + +[[package]] +name = "salsa-macro-rules" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58e354cbac6939b9b09cd9c11fb419a53e64b4a0f755d929f56a09f4cc752e41" + +[[package]] +name = "salsa-macros" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3067861075c2b80608f84ad49fb88f2c7610b94cdf8b4201e79ddee87f8980c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + [[package]] name = "same-file" version = "1.0.6" @@ -1368,6 +1665,12 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "semver" version = "1.0.28" @@ -1503,10 +1806,16 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "stdext" version = "0.1.0" -source = "git+https://github.com/etiennebacher/ark?branch=bump-air-commit#f8fb4d37b48f77a6a8199efc4c0cdfd027ed686c" +source = "git+https://github.com/etiennebacher/ark?branch=bump-air-commit#4f667d4cc8eb762468038144dc58f9e37a0484f9" dependencies = [ "anyhow", "log", @@ -1598,6 +1907,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "thin-vec" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0f7e269b48f0a7dd0146680fa24b50cc67fc0373f086a5b2f99bd084639b482" + [[package]] name = "thiserror" version = "2.0.18" @@ -1763,6 +2078,12 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + [[package]] name = "unicode-bom" version = "2.0.3" diff --git a/Cargo.toml b/Cargo.toml index c183ced91..7dc5c2e5e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,10 +15,15 @@ air_fs = { package = "fs", git = "https://github.com/posit-dev/air", rev = "0ead air_r_parser = { git = "https://github.com/posit-dev/air", rev = "0eadd6938d0316b1fc885c91e7af55d7c8de8530" } air_r_syntax = { git = "https://github.com/posit-dev/air", rev = "0eadd6938d0316b1fc885c91e7af55d7c8de8530" } air_workspace = { package = "workspace", git = "https://github.com/posit-dev/air", rev = "0eadd6938d0316b1fc885c91e7af55d7c8de8530" } +aether_path = { git = "https://github.com/etiennebacher/ark", branch = "bump-air-commit" } oak_core = { git = "https://github.com/etiennebacher/ark", branch = "bump-air-commit" } +oak_db = { git = "https://github.com/etiennebacher/ark", branch = "bump-air-commit" } +oak_ide = { git = "https://github.com/etiennebacher/ark", branch = "bump-air-commit" } oak_index_vec = { git = "https://github.com/etiennebacher/ark", branch = "bump-air-commit" } oak_package_metadata = { git = "https://github.com/etiennebacher/ark", branch = "bump-air-commit" } +oak_scan = { git = "https://github.com/etiennebacher/ark", branch = "bump-air-commit" } oak_semantic = { git = "https://github.com/etiennebacher/ark", branch = "bump-air-commit" } +salsa = "0.26.2" url = "2.5" anyhow = "1.0.94" biome_formatter = { git = "https://github.com/lionel-/biome", rev = "a1296ea6ba363d8b8d8f02181b2a4ce9315c5ef9" } diff --git a/crates/jarl-core/Cargo.toml b/crates/jarl-core/Cargo.toml index f00a29b30..121d6123f 100644 --- a/crates/jarl-core/Cargo.toml +++ b/crates/jarl-core/Cargo.toml @@ -24,10 +24,15 @@ biome_rowan.workspace = true biome_formatter.workspace = true # Semantic analysis (per-file scopes, definitions, uses, use-def map) +aether_path.workspace = true oak_core.workspace = true +oak_db.workspace = true +oak_ide.workspace = true oak_index_vec.workspace = true oak_package_metadata.workspace = true +oak_scan.workspace = true oak_semantic.workspace = true +salsa.workspace = true url.workspace = true # Per-file semantic info (used by semantic-aware lints) diff --git a/crates/jarl-core/src/check.rs b/crates/jarl-core/src/check.rs index 5aae29f32..8d8c53f25 100644 --- a/crates/jarl-core/src/check.rs +++ b/crates/jarl-core/src/check.rs @@ -157,6 +157,9 @@ pub fn lint_fix( return Ok(Vec::new()); } + // Fix mode rewrites the file between iterations, so the on-disk + // contents drift from the database snapshot. Pass `None` to rebuild + // the index from the in-memory `contents` rather than the stale cache. checks = get_checks( &contents, &PathBuf::from(&path), @@ -226,6 +229,12 @@ pub fn get_checks( // calls inject `DefinitionKind::Import` entries via JarlImportsResolver; // the complementary "names read by sourced files" path is still handled // inside `SemanticInfo`. + // + // The index is built here, in the parallel per-file pass, rather than + // pulled from the shared `AnalysisDb`: oak's salsa database is `Send` but + // not `Sync`, so it can't be borrowed across rayon worker threads. Cross- + // file analysis that needs the database runs in the sequential pre-passes + // (`summarize_package_info`, `make_package_analysis`) instead. let semantic = oak_semantic::build_index( &parsed.tree(), jarl_semantic::JarlImportsResolver::new(file), diff --git a/crates/jarl-core/src/db.rs b/crates/jarl-core/src/db.rs new file mode 100644 index 000000000..a4a443659 --- /dev/null +++ b/crates/jarl-core/src/db.rs @@ -0,0 +1,88 @@ +//! Shared analysis database backed by oak's salsa stack. +//! +//! jarl's CLI is a one-shot tool over a list of paths, while oak's +//! [`oak_scan`] machinery is built for editor workspace folders. We bridge +//! the two by scanning only the **package roots** that the linted paths +//! belong to (bounded by `DESCRIPTION` discovery), never the unbounded +//! parent directory of a loose script. That keeps a `jarl /tmp/foo.R` +//! invocation from walking all of `/tmp`. +//! +//! The database is built once in [`crate::check::check`] and shared +//! read-only across the per-file parallel pass: `OakDatabase` is `Send + +//! Sync` and its tracked queries take `&dyn Db`, so cross-file lookups +//! (`File::semantic_index`, `Package::resolve`, `oak_ide::find_references`) +//! run concurrently without cloning storage. + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +use aether_path::FilePath; +use oak_db::{Db, File, OakDatabase}; +use oak_scan::ScanScheduler; + +use crate::package::find_package_root; + +/// A populated, read-only analysis database over the package roots that +/// cover the linted paths. +pub struct AnalysisDb { + db: OakDatabase, +} + +impl AnalysisDb { + /// Scan the package roots covering `paths` into a fresh database. + /// + /// Loose scripts (not inside any R package) contribute no root, so + /// they're simply absent from the database; their per-file analysis + /// falls back to the standalone index builder. Only files under a + /// discovered package root are registered, which is exactly the set + /// that needs cross-file resolution. + pub fn build(paths: &[PathBuf]) -> Self { + let mut db = OakDatabase::new(); + let roots = package_roots(paths); + if !roots.is_empty() { + let mut scheduler = ScanScheduler::new(); + let editor_owned = HashSet::new(); + let mut requests = scheduler.set_workspace_paths(&mut db, &roots, &editor_owned); + // Drain synchronously: jarl has no task pool, so run every scan + // request on this thread and feed follow-ups back until the + // scheduler is idle (oak_scan's documented out-of-crate pattern). + while let Some(request) = requests.pop() { + let completed = request.run(); + requests.extend(scheduler.apply_scan_completed(&mut db, completed, &editor_owned)); + } + } + Self { db } + } + + /// The underlying salsa database, for cross-file queries. + pub fn db(&self) -> &dyn Db { + &self.db + } + + /// The registered [`File`] for `path`, if it was scanned in. + pub fn file_for_path(&self, path: &Path) -> Option { + let file_path = FilePath::from_path_buf(path.to_path_buf())?; + self.db.file_by_path(&file_path) + } +} + +/// The deduplicated set of package roots (directories containing a +/// `DESCRIPTION`) for `paths`, with nested roots collapsed to their +/// outermost ancestor so each tree is scanned once. +fn package_roots(paths: &[PathBuf]) -> Vec { + let mut roots: Vec = paths + .iter() + .filter_map(|path| find_package_root(path)) + .collect::>() + .into_iter() + .collect(); + roots.sort(); + + let mut outermost: Vec = Vec::new(); + for root in roots { + if !outermost.iter().any(|ancestor| root.starts_with(ancestor)) { + outermost.push(root); + } + } + outermost +} diff --git a/crates/jarl-core/src/lib.rs b/crates/jarl-core/src/lib.rs index 1a199c0b7..95470f5d4 100644 --- a/crates/jarl-core/src/lib.rs +++ b/crates/jarl-core/src/lib.rs @@ -13,6 +13,7 @@ pub mod analyze; pub mod check; pub mod checker; pub mod config; +pub mod db; pub mod description; pub mod diagnostic; pub mod directive; From cdee3130c829bb1c6f35600b8534af4a8679fe17 Mon Sep 17 00:00:00 2001 From: etiennebacher Date: Fri, 19 Jun 2026 22:36:55 +0100 Subject: [PATCH 16/39] more --- crates/jarl-core/src/db.rs | 87 ++++++++++++++-- crates/jarl-core/src/description.rs | 2 +- crates/jarl-core/src/package.rs | 121 ++++++++--------------- crates/jarl/tests/integration/exclude.rs | 18 ++-- 4 files changed, 129 insertions(+), 99 deletions(-) diff --git a/crates/jarl-core/src/db.rs b/crates/jarl-core/src/db.rs index a4a443659..69d000ce7 100644 --- a/crates/jarl-core/src/db.rs +++ b/crates/jarl-core/src/db.rs @@ -7,21 +7,34 @@ //! parent directory of a loose script. That keeps a `jarl /tmp/foo.R` //! invocation from walking all of `/tmp`. //! -//! The database is built once in [`crate::check::check`] and shared -//! read-only across the per-file parallel pass: `OakDatabase` is `Send + -//! Sync` and its tracked queries take `&dyn Db`, so cross-file lookups -//! (`File::semantic_index`, `Package::resolve`, `oak_ide::find_references`) -//! run concurrently without cloning storage. +//! The database is built and queried in jarl's *sequential* pre-pass +//! ([`crate::package::make_package_analysis`]), not the parallel per-file +//! pass: oak's `OakDatabase` is `Send` but not `Sync` (it holds per-thread +//! query state), so it can't be borrowed across rayon workers. The pre-pass +//! uses it to enumerate each package's R files — replacing jarl's hand-rolled +//! filesystem walks — and feeds plain `Send` data to the parallel pass. use std::collections::HashSet; use std::path::{Path, PathBuf}; use aether_path::FilePath; -use oak_db::{Db, File, OakDatabase}; +use oak_db::{Db, File, OakDatabase, Package, workspace_files}; use oak_scan::ScanScheduler; use crate::package::find_package_root; +/// One scanned R package: its root directory plus the R source files oak +/// discovered under it, split by load-order classification. +pub struct ScannedPackage { + /// Package root: the directory containing `DESCRIPTION`. + pub root: PathBuf, + /// `R/*.R` files — the package's loadable namespace, in R's load order. + pub r_files: Vec, + /// R files under the package but outside `R/` (`tests/`, `inst/`, + /// `data-raw/`, ...): analysed but not loaded. + pub scripts: Vec, +} + /// A populated, read-only analysis database over the package roots that /// cover the linted paths. pub struct AnalysisDb { @@ -64,15 +77,75 @@ impl AnalysisDb { let file_path = FilePath::from_path_buf(path.to_path_buf())?; self.db.file_by_path(&file_path) } + + /// Every R package oak scanned, with its R-source file paths. + /// + /// This is the database-backed replacement for jarl's manual package + /// discovery: oak's scan already walked each package root (honouring + /// `.gitignore`, applying R's flat-`R/` load rule), so the file sets + /// come straight from the salsa graph instead of a second filesystem walk. + pub fn packages(&self) -> Vec { + let db = self.db(); + let mut seen: HashSet = HashSet::new(); + let mut packages = Vec::new(); + for file in workspace_files(db) { + let Some(package) = file.package(db) else { + continue; + }; + if !seen.insert(package) { + continue; + } + let Some(root) = package + .description_path(db) + .as_path() + .and_then(|path| path.parent()) + .map(|dir| dir.as_std_path().to_path_buf()) + else { + continue; + }; + packages.push(ScannedPackage { + root, + r_files: file_paths(db, package.files(db)), + scripts: file_paths(db, package.scripts(db)), + }); + } + packages + } +} + +/// Resolve a list of database [`File`]s to their filesystem paths, dropping +/// any whose URL has no filesystem path (e.g. virtual documents). +fn file_paths(db: &dyn Db, files: &[File]) -> Vec { + files + .iter() + .filter_map(|file| { + file.path(db) + .as_path() + .map(|p| p.as_std_path().to_path_buf()) + }) + .collect() } /// The deduplicated set of package roots (directories containing a /// `DESCRIPTION`) for `paths`, with nested roots collapsed to their /// outermost ancestor so each tree is scanned once. +/// +/// Paths are absolutized against the working directory first: oak's scanner +/// keys files by `file://` URL and rejects relative paths, and walking up a +/// relative path like `R/foo.R` would otherwise resolve the root to an empty +/// (cwd-relative) path the scanner can't register. fn package_roots(paths: &[PathBuf]) -> Vec { + let cwd = std::env::current_dir().ok(); let mut roots: Vec = paths .iter() - .filter_map(|path| find_package_root(path)) + .filter_map(|path| { + let absolute = if path.is_absolute() { + path.clone() + } else { + cwd.as_ref()?.join(path) + }; + find_package_root(&absolute) + }) .collect::>() .into_iter() .collect(); diff --git a/crates/jarl-core/src/description.rs b/crates/jarl-core/src/description.rs index 91ec924c4..50f6b33ad 100644 --- a/crates/jarl-core/src/description.rs +++ b/crates/jarl-core/src/description.rs @@ -21,7 +21,7 @@ impl Description { let mut packages = Vec::new(); for field_name in what { - if let Some(value) = parsed.get(*field_name) { + if let Some(value) = parsed.get(field_name) { for dep in value.split(',') { // Strip version constraints: "dplyr (>= 1.0.0)" → "dplyr" let name = dep.split('(').next().unwrap_or("").trim(); diff --git a/crates/jarl-core/src/package.rs b/crates/jarl-core/src/package.rs index ec5becacf..ba2a9204c 100644 --- a/crates/jarl-core/src/package.rs +++ b/crates/jarl-core/src/package.rs @@ -231,97 +231,34 @@ pub fn make_package_analysis( return PackageAnalysis::default(); } - // Cache is_in_r_package per unique parent directory so we do at most K - // stat calls (typically 1) instead of N (one per file). - let r_dirs: HashSet = paths - .iter() - .filter(|p| has_r_extension(p)) - .filter_map(|p| p.parent().map(|d| d.to_path_buf())) - .collect(); - - let dir_is_package: HashMap = r_dirs - .into_iter() - .map(|dir| { - let in_pkg = dir - .file_name() - .and_then(|n| n.to_str()) - .is_some_and(|n| n == "R") - && dir.parent().is_some_and(|p| p.join("DESCRIPTION").exists()); - (dir, in_pkg) - }) - .collect(); - - // Collect R/ files that belong to packages. - let r_dir_files: Vec<&PathBuf> = paths - .iter() - .filter(|p| has_r_extension(p)) - .filter(|p| { - p.parent() - .and_then(|d| dir_is_package.get(d)) - .copied() - .unwrap_or(false) - }) - .collect(); - - // Discover package roots and collect excluded R/ files so they still - // contribute to cross-file analysis (both duplicate and unused checks). - // Also collect extra files (tests/, inst/tinytest/, inst/tests/, src/). - let mut extra_files: Vec = Vec::new(); - let mut excluded_r_files: Vec = Vec::new(); - - let package_roots: HashSet = r_dir_files - .iter() - .filter_map(|p| p.parent().and_then(|r| r.parent()).map(|r| r.to_path_buf())) - .collect(); - - // Collect the set of R/ files already in paths (canonicalized for comparison). - let r_dir_file_set: HashSet = r_dir_files - .iter() - .filter_map(|p| std::fs::canonicalize(p).ok()) - .collect(); - - for root in &package_roots { - // Discover ALL R/ files on disk, including excluded ones, so they - // contribute to the cross-file analysis. Diagnostics are only emitted - // for files in config.paths, so excluded files won't produce warnings. - let r_dir = root.join("R"); - if r_dir.is_dir() { - for file in collect_files(&r_dir, has_r_extension) { - if let Ok(canon) = std::fs::canonicalize(&file) - && !r_dir_file_set.contains(&canon) - { - excluded_r_files.push(file); - } - } + // File discovery comes from oak's scan of each package root rather than a + // second filesystem walk here. The scan already enumerated every `R/` file + // (gitignore-aware, applying R's flat-`R/` load rule) and the package's + // other R sources. Diagnostics are only emitted for files in `config.paths`, + // so `R/` files outside the lint set still feed cross-file analysis without + // producing warnings. `src/` C/C++ files aren't R, so oak doesn't see them; + // we walk those directly for the unused-function check. + let db = crate::db::AnalysisDb::build(paths); + let mut all_files: Vec<(PathBuf, FileScope)> = Vec::new(); + for package in db.packages() { + for r_file in package.r_files { + all_files.push((r_file, FileScope::R)); } - if check_unused { - // Collect test/tinytest R files - for dir_name in &["inst/tinytest", "inst/tests", "tests"] { - let dir = root.join(dir_name); - if dir.is_dir() { - extra_files.extend(collect_files(&dir, has_r_extension)); + for script in package.scripts { + if let Some(scope) = package_test_scope(&package.root, &script) { + all_files.push((script, scope)); } } - // Collect C/C++ files in src/ - let src_dir = root.join("src"); + let src_dir = package.root.join("src"); if src_dir.is_dir() { - extra_files.extend(collect_files(&src_dir, has_cpp_extension)); + for file in collect_files(&src_dir, has_cpp_extension) { + all_files.push((file, FileScope::Src)); + } } } } - // Build the list of all files to scan in parallel, each tagged with its scope. - let all_files: Vec<(&Path, FileScope)> = r_dir_files - .iter() - .map(|p| (p.as_path(), FileScope::R)) - .chain(excluded_r_files.iter().map(|p| (p.as_path(), FileScope::R))) - .chain(extra_files.iter().map(|p| { - let scope = file_scope_from_path(p); - (p.as_path(), scope) - })) - .collect(); - // Single parallel scan: read each file once. All R files get // scan_top_level_assignments; Src files only get scan_symbols. let shared_data: Vec = all_files @@ -414,6 +351,26 @@ pub(crate) fn file_scope_from_path(path: &Path) -> FileScope { FileScope::Tests } +/// Classify an oak-discovered package script as a test-scope file, or `None` +/// when it isn't one the unused-function check considers. Mirrors exactly the +/// directories the previous filesystem walk collected — `tests/`, +/// `inst/tinytest/`, `inst/tests/` — so `data-raw/`, `vignettes/`, and other +/// `inst/` subdirectories are excluded rather than swept in. +fn package_test_scope(root: &Path, path: &Path) -> Option { + let rel = path.strip_prefix(root).ok()?; + let mut components = rel + .components() + .map(|c| c.as_os_str().to_string_lossy().into_owned()); + match components.next()?.as_str() { + "tests" => Some(FileScope::Tests), + "inst" => match components.next()?.as_str() { + "tinytest" | "tests" => Some(FileScope::Inst), + _ => None, + }, + _ => None, + } +} + /// Check whether a file is under a recognized package subdirectory /// (tests/, inst/tinytest, inst/tests, src/) relative to the package root. fn is_known_package_scope(path: &Path, package_root: &Path) -> bool { diff --git a/crates/jarl/tests/integration/exclude.rs b/crates/jarl/tests/integration/exclude.rs index 71b6b61d2..a63f62681 100644 --- a/crates/jarl/tests/integration/exclude.rs +++ b/crates/jarl/tests/integration/exclude.rs @@ -7,7 +7,7 @@ use crate::helpers::{CliTest, CommandExt}; #[test] fn test_excluded_file_contributes_symbols() -> anyhow::Result<()> { let case = CliTest::with_files([ - ("DESCRIPTION", ""), + ("DESCRIPTION", "Package: testpkg\nVersion: 1.0.0\n"), ("NAMESPACE", ""), ("R/foo2.R", "f <- function() 1 + 1\n"), ("R/foo.R", "f()\n"), @@ -47,7 +47,7 @@ exclude = ["R/foo.R"] #[test] fn test_excluded_file_not_in_r_folder_contributes_symbols() -> anyhow::Result<()> { let case = CliTest::with_files([ - ("DESCRIPTION", ""), + ("DESCRIPTION", "Package: testpkg\nVersion: 1.0.0\n"), ("NAMESPACE", ""), ("R/foo.R", "f <- function() 1 + 1\n"), ("tests/foo.R", "f()\n"), @@ -88,7 +88,7 @@ exclude = ["R/foo.R"] #[test] fn test_included_file_contributes_symbols() -> anyhow::Result<()> { let case = CliTest::with_files([ - ("DESCRIPTION", ""), + ("DESCRIPTION", "Package: testpkg\nVersion: 1.0.0\n"), ("NAMESPACE", ""), ("R/foo2.R", "f <- function() 1 + 1\n"), ("R/foo.R", "f()\n"), @@ -131,7 +131,7 @@ include = ["R/foo2.R"] #[test] fn test_excluded_file_contributes_assignments() -> anyhow::Result<()> { let case = CliTest::with_files([ - ("DESCRIPTION", ""), + ("DESCRIPTION", "Package: testpkg\nVersion: 1.0.0\n"), ("NAMESPACE", ""), ("R/foo.R", "f <- function() 1\n"), ("R/foo2.R", "f <- function() 2\n"), @@ -181,7 +181,7 @@ exclude = ["R/foo.R"] #[test] fn test_included_file_contributes_assignments() -> anyhow::Result<()> { let case = CliTest::with_files([ - ("DESCRIPTION", ""), + ("DESCRIPTION", "Package: testpkg\nVersion: 1.0.0\n"), ("NAMESPACE", ""), ("R/foo.R", "f <- function() 1\n"), ("R/foo2.R", "f <- function() 2\n"), @@ -383,7 +383,7 @@ fn test_cli_exclude_wrong_glob_patterns() -> anyhow::Result<()> { #[test] fn test_cli_excluded_file_contributes_symbols() -> anyhow::Result<()> { let case = CliTest::with_files([ - ("DESCRIPTION", ""), + ("DESCRIPTION", "Package: testpkg\nVersion: 1.0.0\n"), ("NAMESPACE", ""), ("R/foo2.R", "f <- function() 1 + 1\n"), ("R/foo.R", "f()\n"), @@ -421,7 +421,7 @@ fn test_cli_excluded_file_contributes_symbols() -> anyhow::Result<()> { #[test] fn test_cli_excluded_file_contributes_assignments() -> anyhow::Result<()> { let case = CliTest::with_files([ - ("DESCRIPTION", ""), + ("DESCRIPTION", "Package: testpkg\nVersion: 1.0.0\n"), ("NAMESPACE", ""), ("R/foo.R", "f <- function() 1\n"), ("R/foo2.R", "f <- function() 2\n"), @@ -470,7 +470,7 @@ fn test_cli_excluded_file_contributes_assignments() -> anyhow::Result<()> { #[test] fn test_generated_file_skipped_but_contributes_symbols() -> anyhow::Result<()> { let case = CliTest::with_files([ - ("DESCRIPTION", ""), + ("DESCRIPTION", "Package: testpkg\nVersion: 1.0.0\n"), ("NAMESPACE", ""), ("R/foo2.R", "f <- function() 1 + 1\n"), ("R/foo.R", "# Generated by foo\nany(is.na(x))\nf()\n"), @@ -505,7 +505,7 @@ fn test_generated_file_skipped_but_contributes_symbols() -> anyhow::Result<()> { #[test] fn test_must_start_with_generated_by_to_be_ignored() -> anyhow::Result<()> { let case = CliTest::with_files([ - ("DESCRIPTION", ""), + ("DESCRIPTION", "Package: testpkg\nVersion: 1.0.0\n"), ("NAMESPACE", ""), ("R/foo2.R", "f <- function() 1 + 1\n"), ( From c300cd50c50f0fa7710a7f49d3c76eb476544385 Mon Sep 17 00:00:00 2001 From: etiennebacher Date: Sat, 20 Jun 2026 12:20:56 +0100 Subject: [PATCH 17/39] fix for .() in bquote() --- crates/jarl-core/src/description.rs | 2 +- .../src/lints/base/unused_object/mod.rs | 36 ++++++++++++++-- crates/jarl-semantic/src/lib.rs | 43 +++++++++++++++++-- 3 files changed, 74 insertions(+), 7 deletions(-) diff --git a/crates/jarl-core/src/description.rs b/crates/jarl-core/src/description.rs index 91ec924c4..50f6b33ad 100644 --- a/crates/jarl-core/src/description.rs +++ b/crates/jarl-core/src/description.rs @@ -21,7 +21,7 @@ impl Description { let mut packages = Vec::new(); for field_name in what { - if let Some(value) = parsed.get(*field_name) { + if let Some(value) = parsed.get(field_name) { for dep in value.split(',') { // Strip version constraints: "dplyr (>= 1.0.0)" → "dplyr" let name = dep.split('(').next().unwrap_or("").trim(); diff --git a/crates/jarl-core/src/lints/base/unused_object/mod.rs b/crates/jarl-core/src/lints/base/unused_object/mod.rs index cde9d0239..5f4052a88 100644 --- a/crates/jarl-core/src/lints/base/unused_object/mod.rs +++ b/crates/jarl-core/src/lints/base/unused_object/mod.rs @@ -611,6 +611,39 @@ f <- function() { ); } + #[test] + fn test_bquote_unquote_counts_as_use() { + // `bquote(x)` quotes `x`, so its value is unused -> report. + assert_snapshot!( + snapshot_lint("x <- 1\nbquote(x)"), + @r" + warning: unused_object + --> :1:1 + | + 1 | x <- 1 + | - Object `x` is defined but never used. + | + Found 1 error. + " + ); + // `bquote(.(x))` unquotes (evaluates) `x`, so it is used -> no report. + expect_no_lint("x <- 1\nbquote(.(x))", "unused_object", None); + // The unquoted use resolves to the latest definition, so the shadowed + // `x <- 1` is still dead and reported. + assert_snapshot!( + snapshot_lint("x <- 1\nx <- 2\nbquote(.(x))"), + @r" + warning: unused_object + --> :1:1 + | + 1 | x <- 1 + | - Object `x` is defined but never used. + | + Found 1 error. + " + ); + } + #[test] fn test_with_assignment_pipe() { // should lint: re-assigned `x` isn't used @@ -642,7 +675,6 @@ x + 1" #[test] fn test_assign() { - // TODO: this should report env // shouldn't lint: env is used as argument to assign() expect_no_lint( " @@ -682,7 +714,6 @@ env", #[test] fn test_delayed_assign() { - // TODO: this should report env // shouldn't lint: env is used as argument to delayedAssign() expect_no_lint( " @@ -722,7 +753,6 @@ env", #[test] fn test_make_active_binding() { - // TODO: this should report env // shouldn't lint: env is used as argument to makeActiveBinding() expect_no_lint( " diff --git a/crates/jarl-semantic/src/lib.rs b/crates/jarl-semantic/src/lib.rs index 2a737f8bd..da7ff6732 100644 --- a/crates/jarl-semantic/src/lib.rs +++ b/crates/jarl-semantic/src/lib.rs @@ -38,8 +38,12 @@ pub struct SemanticInfo<'a> { /// this set is treated as used. synthetic_used_names: HashSet, /// Identifier `Use` ranges that should be ignored because they sit inside - /// an NSE call argument (`quote(x)`, `substitute(…)`, `bquote(…)`, …). + /// an NSE call argument (`quote(x)`, `substitute(…)`, …). nse_ranges: Vec, + /// Ranges inside an NSE argument that are nonetheless evaluated and so + /// carve a hole back out of [`Self::nse_ranges`]: the `.()` operands of + /// `bquote(...)`. A use inside one of these counts as a real use. + unquote_ranges: Vec, /// Bodies of `local({...})` calls. local_body_ranges: Vec, /// Ranges of formula RHSes (`~ rhs`). @@ -66,6 +70,7 @@ impl<'a> SemanticInfo<'a> { file, synthetic_used_names: HashSet::new(), nse_ranges: Vec::new(), + unquote_ranges: Vec::new(), local_body_ranges: Vec::new(), formula_ranges: Vec::new(), reaching_used: HashSet::new(), @@ -312,11 +317,21 @@ impl<'a> SemanticInfo<'a> { let arg_values: Vec<(Option, RSyntaxNode)> = call_args(call); match name.as_str() { - "quote" | "substitute" | "bquote" | "enquote" | "expression" | "Quote" => { + "quote" | "substitute" | "enquote" | "expression" | "Quote" => { for (_, value) in &arg_values { self.nse_ranges.push(value.text_trimmed_range()); } } + // `bquote` quotes its argument, but `.()` unquotes (evaluates) the + // wrapped expression. So the argument is NSE — `bquote(x)` does not + // use `x` — except for identifiers inside `.()`, which are real + // uses: `bquote(.(x))` does use `x`. + "bquote" => { + for (_, value) in &arg_values { + self.nse_ranges.push(value.text_trimmed_range()); + self.collect_bquote_unquoted_uses(value); + } + } "do.call" | "match.fun" | "Recall" | "getFunction" => { if let Some((_, first)) = arg_values.first() && let Some(s) = string_literal_value(first) @@ -382,6 +397,26 @@ impl<'a> SemanticInfo<'a> { } } + /// Within a `bquote()` argument, operands wrapped in `.()` are unquoted + /// (evaluated), so uses inside them are real. Record each `.()` operand + /// range as a hole in the surrounding NSE range pushed for the argument. + fn collect_bquote_unquoted_uses(&mut self, arg: &RSyntaxNode) { + for node in arg.descendants() { + if node.kind() != RSyntaxKind::R_CALL { + continue; + } + let Some(call) = node.clone().cast::() else { + continue; + }; + if call_name(&call).as_deref() != Some(".") { + continue; + } + for (_, value) in call_args(&call) { + self.unquote_ranges.push(value.text_trimmed_range()); + } + } + } + // ── Internal: reach / closure analysis ──────────────────────────── /// Collect every definition reached by a non-NSE use, in every scope. @@ -395,7 +430,9 @@ impl<'a> SemanticInfo<'a> { let index = self.index; for &scope_id in scopes { for (use_id, u) in index.uses(scope_id).iter() { - if in_any_range(u.range(), &self.nse_ranges) { + if in_any_range(u.range(), &self.nse_ranges) + && !in_any_range(u.range(), &self.unquote_ranges) + { continue; } for (def_scope, def_id) in index.reaching_definitions(scope_id, use_id) { From bfcce8107b7faf0df9cc94c37b5d79547d14e222 Mon Sep 17 00:00:00 2001 From: etiennebacher Date: Sat, 20 Jun 2026 14:26:02 +0100 Subject: [PATCH 18/39] more --- crates/jarl-core/src/analyze/document.rs | 10 +-- crates/jarl-core/src/check.rs | 32 ++++--- crates/jarl-core/src/db.rs | 73 ++++++++++++++- .../lints/base/unused_object/unused_object.rs | 32 ++++++- crates/jarl-core/src/package.rs | 48 +++++++++- .../jarl/tests/integration/unused_object.rs | 88 +++++++++++++++++++ 6 files changed, 259 insertions(+), 24 deletions(-) diff --git a/crates/jarl-core/src/analyze/document.rs b/crates/jarl-core/src/analyze/document.rs index 313c7120f..c303872fe 100644 --- a/crates/jarl-core/src/analyze/document.rs +++ b/crates/jarl-core/src/analyze/document.rs @@ -17,14 +17,14 @@ use crate::lints::comments::unexplained_suppression::unexplained_suppression::un use crate::lints::comments::unmatched_range_suppression::unmatched_range_suppression::{ unmatched_range_suppression_end, unmatched_range_suppression_start, }; +use crate::package::PackageFileAnalysis; use crate::rule_set::Rule; pub(crate) fn check_document( expressions: &RExpressionList, syntax: &RSyntaxNode, checker: &mut Checker, - duplicate_assignments: &[(String, biome_rowan::TextRange, String)], - unused_functions: &[(String, biome_rowan::TextRange, String)], + package: &PackageFileAnalysis, semantic: Option<&SemanticIndex>, ) -> anyhow::Result<()> { // --- Document-level analysis --- @@ -42,7 +42,7 @@ pub(crate) fn check_document( if checker.is_rule_enabled(Rule::UnusedObject) && let Some(semantic) = semantic { - unused_object(&expressions, semantic, checker)?; + unused_object(&expressions, semantic, &package.cross_file_used, checker)?; } // --- Comment/suppression checks --- @@ -114,7 +114,7 @@ pub(crate) fn check_document( // Emit package-level diagnostics before suppression filtering so that // # jarl-ignore and # jarl-ignore-file comments can suppress them. if checker.is_rule_enabled(Rule::DuplicatedFunctionDefinition) { - for (name, range, help) in duplicate_assignments { + for (name, range, help) in &package.duplicate_assignments { checker.report_diagnostic(Some(Diagnostic::new( ViolationData::new( "duplicated_function_definition".to_string(), @@ -128,7 +128,7 @@ pub(crate) fn check_document( } if checker.is_rule_enabled(Rule::UnusedFunction) { - for (name, range, help) in unused_functions { + for (name, range, help) in &package.unused_functions { checker.report_diagnostic(Some(Diagnostic::new( ViolationData::new( "unused_function".to_string(), diff --git a/crates/jarl-core/src/check.rs b/crates/jarl-core/src/check.rs index 8d8c53f25..40044515f 100644 --- a/crates/jarl-core/src/check.rs +++ b/crates/jarl-core/src/check.rs @@ -1,7 +1,7 @@ use crate::error::ParseError; use crate::package::{ - FilePackageInfo, FileScope, PackageAnalysis, PackageContext, make_package_analysis, - summarize_package_info, + FilePackageInfo, FileScope, PackageAnalysis, PackageContext, PackageFileAnalysis, + make_package_analysis, summarize_package_info, }; use crate::roxygen::{extract_roxygen_examples, remap_roxygen_fix, remap_roxygen_range}; use crate::suppression::SuppressionManager; @@ -252,12 +252,7 @@ pub fn get_checks( ); // Look up per-file data from PackageAnalysis - let duplicate_assignments = pkg - .duplicate_assignments - .get(file) - .cloned() - .unwrap_or_default(); - let unused_functions = pkg.unused_functions.get(file).cloned().unwrap_or_default(); + let package_file = PackageFileAnalysis::for_file(pkg, file); // We run checks at expression-level. This gathers all violations, no matter // whether they are suppressed or not. They are filtered out in the next @@ -291,8 +286,7 @@ pub fn get_checks( expressions, syntax, &mut checker, - &duplicate_assignments, - &unused_functions, + &package_file, Some(&semantic), )?; @@ -440,7 +434,13 @@ fn get_checks_roxygen( // otherwise unnecessary here (no package-level analysis, no // suppression-related diagnostics to report). if has_suppressions { - check_document(expressions, &syntax, &mut checker, &[], &[], None)?; + check_document( + expressions, + &syntax, + &mut checker, + &PackageFileAnalysis::default(), + None, + )?; } for mut d in checker.diagnostics { @@ -492,8 +492,14 @@ fn get_checks_rmd(contents: &str, file: &Path, config: &Config) -> Result = checker diff --git a/crates/jarl-core/src/db.rs b/crates/jarl-core/src/db.rs index 69d000ce7..89b9c13fd 100644 --- a/crates/jarl-core/src/db.rs +++ b/crates/jarl-core/src/db.rs @@ -14,12 +14,15 @@ //! uses it to enumerate each package's R files — replacing jarl's hand-rolled //! filesystem walks — and feeds plain `Send` data to the parallel pass. -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use aether_path::FilePath; +use air_r_parser::RParserOptions; use oak_db::{Db, File, OakDatabase, Package, workspace_files}; use oak_scan::ScanScheduler; +use oak_semantic::ScopeId; +use oak_semantic::semantic_index::SemanticIndex; use crate::package::find_package_root; @@ -111,6 +114,74 @@ impl AnalysisDb { } packages } + + /// For each scanned file, the set of its top-level object names that are + /// read from *another* file in the same package. + /// + /// A package's R files share one namespace, so a top-level binding defined + /// in one file and read in another is used even when its own file never + /// reads it. We enumerate every use in every scanned file and resolve it + /// with oak's cross-file [`File::resolve_at`]: a use that binds to a + /// file-scope definition in a *different* file is a cross-file read of that + /// package-level object. The defining file's name is recorded against the + /// read so the lint can suppress it. + /// + /// Keyed by relativized file path to match the lint's per-file lookup. + pub fn cross_file_used_objects(&self) -> HashMap> { + let db = self.db(); + + // Build a throwaway index per file to enumerate use sites, and collect + // the package's top-level binding names. Only those names can be the + // target of a cross-file read, so probing just their uses skips the + // locals, package functions, and library symbols that dominate a file. + let mut indices: Vec<(File, SemanticIndex)> = Vec::new(); + let mut candidates: HashSet = HashSet::new(); + for &file in workspace_files(db) { + let parsed = + air_r_parser::parse(file.source_text(db).as_str(), RParserOptions::default()); + if parsed.has_error() { + continue; + } + let index = + oak_semantic::build_index(&parsed.tree(), oak_semantic::NoopImportsResolver); + candidates.extend(index.exports().keys().map(|name| name.to_string())); + indices.push((file, index)); + } + + let file_scope = ScopeId::from(0); + let mut used: HashMap> = HashMap::new(); + for (file, index) in &indices { + for scope in index.scope_ids() { + let symbols = index.symbols(scope); + for (_use_id, use_site) in index.uses(scope).iter() { + let name = symbols.symbol(use_site.symbol()).name(); + if !candidates.contains(name) { + continue; + } + for def in file.resolve_at(db, use_site.range().start()) { + // Only top-level bindings are shared across the package, + // and a read in the binding's own file is already + // handled by the per-file analysis. + if def.scope(db) != file_scope || def.file(db) == *file { + continue; + } + let Some(path) = def + .file(db) + .path(db) + .as_path() + .map(|p| p.as_std_path().to_path_buf()) + else { + continue; + }; + used.entry(PathBuf::from(crate::fs::relativize_path(&path))) + .or_default() + .insert(def.name(db).text(db).as_str().to_string()); + } + } + } + } + used + } } /// Resolve a list of database [`File`]s to their filesystem paths, dropping diff --git a/crates/jarl-core/src/lints/base/unused_object/unused_object.rs b/crates/jarl-core/src/lints/base/unused_object/unused_object.rs index 321c09844..3080bd820 100644 --- a/crates/jarl-core/src/lints/base/unused_object/unused_object.rs +++ b/crates/jarl-core/src/lints/base/unused_object/unused_object.rs @@ -31,6 +31,7 @@ use crate::diagnostic::{Diagnostic, Fix, ViolationData}; pub fn unused_object( expressions: &[RSyntaxNode], semantic: &SemanticIndex, + cross_file_used: &std::collections::HashSet, checker: &mut Checker, ) -> anyhow::Result<()> { let Some(first) = expressions.first() else { @@ -50,7 +51,12 @@ pub fn unused_object( if info.is_definition_used(scope_id, def_id, def) { continue; } - if scope_id == top_level && is_exported(semantic, exports, scope_id, def) { + // Top-level bindings are shared across a package's files, so an + // object read from a sibling file (or exported) is still used. + if scope_id == top_level + && (is_exported(semantic, exports, scope_id, def) + || is_used_cross_file(semantic, cross_file_used, scope_id, def)) + { continue; } diagnostics.push(make_diagnostic(semantic, scope_id, def, info.root())); @@ -61,6 +67,7 @@ pub fn unused_object( semantic, &info, exports, + cross_file_used, )); for d in diagnostics { @@ -111,6 +118,22 @@ fn is_exported( exports.contains(name) } +/// True when this top-level binding is read from another file in the same +/// package. `cross_file_used` is precomputed from oak's cross-file resolution +/// (see [`crate::db::AnalysisDb::cross_file_used_objects`]). +fn is_used_cross_file( + semantic: &SemanticIndex, + cross_file_used: &std::collections::HashSet, + scope_id: ScopeId, + def: &Definition, +) -> bool { + if cross_file_used.is_empty() { + return false; + } + let name = semantic.symbols(scope_id).symbol(def.symbol()).name(); + cross_file_used.contains(name) +} + fn make_diagnostic( semantic: &SemanticIndex, scope_id: ScopeId, @@ -143,6 +166,7 @@ fn collect_assignment_pipe_diagnostics( semantic: &SemanticIndex, info: &SemanticInfo<'_>, exports: &std::collections::HashSet, + cross_file_used: &std::collections::HashSet, ) -> Vec { let mut out = Vec::new(); for expr in expressions { @@ -178,9 +202,11 @@ fn collect_assignment_pipe_diagnostics( .any(|(_, u)| u.symbol() == sym && u.range().start() >= expr_end) }); let closure_use = info.is_used_in_nested_scope(scope_id, &name); - let exported = scope_id == ScopeId::from(0) && exports.contains(&name); + let top_level = scope_id == ScopeId::from(0); + let exported = top_level && exports.contains(&name); + let cross_file = top_level && cross_file_used.contains(&name); - if !later_use && !closure_use && !exported { + if !later_use && !closure_use && !exported && !cross_file { out.push(Diagnostic::new( ViolationData::new( "unused_object".to_string(), diff --git a/crates/jarl-core/src/package.rs b/crates/jarl-core/src/package.rs index ba2a9204c..af159de5a 100644 --- a/crates/jarl-core/src/package.rs +++ b/crates/jarl-core/src/package.rs @@ -80,6 +80,37 @@ pub struct PackageAnalysis { /// help)` triples for functions that are defined but never called and not /// exported. pub unused_functions: HashMap>, + /// Per-file set of top-level object names that are read from *another* + /// file in the same package. Keyed by relativized file path. All of a + /// package's R files share one namespace, so a top-level binding defined + /// in one file and used in another is not unused; `unused_object` + /// consults this to avoid flagging such cross-file-used objects. Computed + /// from oak's cross-file name resolution (`File::resolve_at`). + pub cross_file_used: HashMap>, +} + +/// The entries of [`PackageAnalysis`] for a single file. Bundled so the +/// document-level checks take one argument instead of one per cross-file map. +#[derive(Clone, Debug, Default)] +pub struct PackageFileAnalysis { + pub duplicate_assignments: Vec<(String, TextRange, String)>, + pub unused_functions: Vec<(String, TextRange, String)>, + pub cross_file_used: HashSet, +} + +impl PackageFileAnalysis { + /// Pull the entries for `file` out of the package-wide analysis. + pub fn for_file(pkg: &PackageAnalysis, file: &Path) -> Self { + Self { + duplicate_assignments: pkg + .duplicate_assignments + .get(file) + .cloned() + .unwrap_or_default(), + unused_functions: pkg.unused_functions.get(file).cloned().unwrap_or_default(), + cross_file_used: pkg.cross_file_used.get(file).cloned().unwrap_or_default(), + } + } } /// Classify every file and pre-compute per-package metadata in one pass. @@ -226,8 +257,9 @@ pub fn make_package_analysis( let rules = &config.rules_to_apply; let check_duplicates = rules.contains(&Rule::DuplicatedFunctionDefinition); let check_unused = rules.contains(&Rule::UnusedFunction); + let check_unused_object = rules.contains(&Rule::UnusedObject); - if !check_duplicates && !check_unused { + if !check_duplicates && !check_unused && !check_unused_object { return PackageAnalysis::default(); } @@ -324,7 +356,19 @@ pub fn make_package_analysis( HashMap::new() }; - PackageAnalysis { duplicate_assignments, unused_functions } + // Reuse the database scanned above: resolve every use across the package's + // files to find top-level objects read from another file. + let cross_file_used = if check_unused_object { + db.cross_file_used_objects() + } else { + HashMap::new() + }; + + PackageAnalysis { + duplicate_assignments, + unused_functions, + cross_file_used, + } } /// Determine the `FileScope` for a non-R/ file based on its path. diff --git a/crates/jarl/tests/integration/unused_object.rs b/crates/jarl/tests/integration/unused_object.rs index d317f0556..ee44c6947 100644 --- a/crates/jarl/tests/integration/unused_object.rs +++ b/crates/jarl/tests/integration/unused_object.rs @@ -41,6 +41,94 @@ fn test_exported_alias_not_flagged() -> anyhow::Result<()> { Ok(()) } +#[test] +fn test_object_used_in_another_package_file_not_flagged() -> anyhow::Result<()> { + // A top-level object defined in one file ("foo") and read in another is + // used even though its own file never reads it. + let case = CliTest::with_files([ + ( + "DESCRIPTION", + "Package: testpkg\nTitle: Test\nVersion: 0.0.1\n", + ), + ( + "NAMESPACE", + "# Generated by roxygen2: do not edit by hand\n", + ), + ("R/foo1.R", "foo <- new.env(parent = emptyenv())\n"), + ("R/foo2.R", "out <- list(\"foo\" = foo)\nprint(out)\n"), + ])?; + + insta::assert_snapshot!( + &mut case + .command() + .arg("check") + .arg(".") + .arg("--select") + .arg("unused_object") + .run() + .normalize_os_executable_name(), + @" + + success: true + exit_code: 0 + ----- stdout ----- + ── Summary ────────────────────────────────────── + All checks passed! + + ----- stderr ----- + " + ); + + Ok(()) +} + +#[test] +fn test_object_unused_across_package_files_is_flagged() -> anyhow::Result<()> { + let case = CliTest::with_files([ + ( + "DESCRIPTION", + "Package: testpkg\nTitle: Test\nVersion: 0.0.1\n", + ), + ( + "NAMESPACE", + "# Generated by roxygen2: do not edit by hand\n", + ), + ("R/foo1.R", "helper_obj <- new.env(parent = emptyenv())\n"), + ("R/foo2.R", "x <- 1\nprint(x)\n"), + ])?; + + insta::assert_snapshot!( + &mut case + .command() + .arg("check") + .arg(".") + .arg("--select") + .arg("unused_object") + .run() + .normalize_os_executable_name(), + @r" + + success: false + exit_code: 1 + ----- stdout ----- + warning: unused_object + --> R/foo1.R:1:1 + | + 1 | helper_obj <- new.env(parent = emptyenv()) + | ---------- Object `helper_obj` is defined but never used. + | + + + ── Summary ────────────────────────────────────── + Found 1 error. + + ----- stderr ----- + " + ); + + Ok(()) +} + #[test] fn test_unexported_alias_is_flagged() -> anyhow::Result<()> { // Same code, but no NAMESPACE export — `summarize_each` is dead. From c6afd40ec983f2b37982c498998e60948cdd78e5 Mon Sep 17 00:00:00 2001 From: etiennebacher Date: Sun, 21 Jun 2026 13:08:02 +0100 Subject: [PATCH 19/39] fix object used in formula --- crates/jarl-core/src/lints/base/unused_object/mod.rs | 11 +++++++++++ crates/jarl-semantic/src/lib.rs | 8 ++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/jarl-core/src/lints/base/unused_object/mod.rs b/crates/jarl-core/src/lints/base/unused_object/mod.rs index 5f4052a88..40fc9a16f 100644 --- a/crates/jarl-core/src/lints/base/unused_object/mod.rs +++ b/crates/jarl-core/src/lints/base/unused_object/mod.rs @@ -919,6 +919,17 @@ for (i in 1:2) { ); } + #[test] + fn test_object_used_in_formula_is_used() { + expect_no_lint( + " + X <- 2 + lm(1 ~ X)", + "unused_object", + None, + ); + } + // --------------------------------------------------------------- // source() cross-file resolution // --------------------------------------------------------------- diff --git a/crates/jarl-semantic/src/lib.rs b/crates/jarl-semantic/src/lib.rs index da7ff6732..500d5d1f1 100644 --- a/crates/jarl-semantic/src/lib.rs +++ b/crates/jarl-semantic/src/lib.rs @@ -287,10 +287,14 @@ impl<'a> SemanticInfo<'a> { return; }; let op_text = op.text_trimmed(); - // Formulas are `R_BINARY_EXPRESSION` with a `~` operator. + // Formulas are `R_BINARY_EXPRESSION` with a `~` operator. Only an `=` + // inside a formula is non-standard (it's named-arg syntax, not an + // assignment), so the formula range is recorded to suppress those + // definitions. Identifier *reads* in a formula still consume bindings: + // `X <- 2; lm(1 ~ X)` looks `X` up at evaluation time, so the formula + // is deliberately not added to `nse_ranges`. if op_text == "~" { self.formula_ranges.push(bin.syntax().text_trimmed_range()); - self.nse_ranges.push(bin.syntax().text_trimmed_range()); return; } From c6af3b66e9b150c9824d4fc35bf9cff26c4354bd Mon Sep 17 00:00:00 2001 From: etiennebacher Date: Sun, 21 Jun 2026 13:49:44 +0100 Subject: [PATCH 20/39] do not consider assignments in quoting functions --- crates/jarl-core/src/lints/base/unused_object/mod.rs | 7 +++++++ .../src/lints/base/unused_object/unused_object.rs | 6 ++++++ crates/jarl-semantic/src/lib.rs | 11 +++++++---- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/crates/jarl-core/src/lints/base/unused_object/mod.rs b/crates/jarl-core/src/lints/base/unused_object/mod.rs index 40fc9a16f..cf72b1d05 100644 --- a/crates/jarl-core/src/lints/base/unused_object/mod.rs +++ b/crates/jarl-core/src/lints/base/unused_object/mod.rs @@ -887,6 +887,13 @@ for (i in 1:2) { ); } + #[test] + fn test_assignment_inside_nse_is_not_definition() { + // `x <- 2` inside `quote()` is quoted code, not a real assignment. + expect_no_lint("as.call(quote(x <- 2))", "unused_object", None); + expect_no_lint("substitute(y <- 1)", "unused_object", None); + } + #[test] fn test_nse_in_same_call() { expect_no_lint( diff --git a/crates/jarl-core/src/lints/base/unused_object/unused_object.rs b/crates/jarl-core/src/lints/base/unused_object/unused_object.rs index 3080bd820..d344e7b4d 100644 --- a/crates/jarl-core/src/lints/base/unused_object/unused_object.rs +++ b/crates/jarl-core/src/lints/base/unused_object/unused_object.rs @@ -102,6 +102,12 @@ fn should_lint_definition(info: &SemanticInfo<'_>, def: &Definition) -> bool { return false; } + // An assignment inside an NSE context (`quote(x <- 2)`, …) is quoted code, + // not a real definition. + if info.is_in_nse(def.range()) { + return false; + } + true } diff --git a/crates/jarl-semantic/src/lib.rs b/crates/jarl-semantic/src/lib.rs index 500d5d1f1..9d8b578a7 100644 --- a/crates/jarl-semantic/src/lib.rs +++ b/crates/jarl-semantic/src/lib.rs @@ -124,8 +124,13 @@ impl<'a> SemanticInfo<'a> { in_any_range(range, &self.formula_ranges) } + /// True when `range` sits in a quoted NSE context (`quote(...)`, + /// `substitute(...)`, `bquote(...)`, …) where code is captured rather than + /// evaluated, so neither an assignment nor a read there touches the live + /// binding. The `.()` unquote holes inside `bquote` are evaluated, so + /// ranges within them are excluded. pub fn is_in_nse(&self, range: TextRange) -> bool { - in_any_range(range, &self.nse_ranges) + in_any_range(range, &self.nse_ranges) && !in_any_range(range, &self.unquote_ranges) } pub fn has_synthetic_use(&self, name: &str) -> bool { @@ -434,9 +439,7 @@ impl<'a> SemanticInfo<'a> { let index = self.index; for &scope_id in scopes { for (use_id, u) in index.uses(scope_id).iter() { - if in_any_range(u.range(), &self.nse_ranges) - && !in_any_range(u.range(), &self.unquote_ranges) - { + if self.is_in_nse(u.range()) { continue; } for (def_scope, def_id) in index.reaching_definitions(scope_id, use_id) { From 1541398229c72e7af249e8ee433e6b27a479b0ee Mon Sep 17 00:00:00 2001 From: etiennebacher Date: Sun, 21 Jun 2026 14:03:42 +0100 Subject: [PATCH 21/39] do not count assignment in quote-like functions are live bindings --- .../src/lints/base/unused_object/mod.rs | 11 +++++ crates/jarl-semantic/src/lib.rs | 45 ++++++++++++++++++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/crates/jarl-core/src/lints/base/unused_object/mod.rs b/crates/jarl-core/src/lints/base/unused_object/mod.rs index cf72b1d05..7de59bf28 100644 --- a/crates/jarl-core/src/lints/base/unused_object/mod.rs +++ b/crates/jarl-core/src/lints/base/unused_object/mod.rs @@ -894,6 +894,17 @@ for (i in 1:2) { expect_no_lint("substitute(y <- 1)", "unused_object", None); } + #[test] + fn test_nse_assignment_does_not_shadow_real_definition() { + // The quoted `x <- 2` must not kill the real `x <- 1`; `print(x)` + // reads the live binding (which is still `1`). + expect_no_lint( + "x <- 1\nsubstitute(x <- 2)\nprint(x)", + "unused_object", + None, + ); + } + #[test] fn test_nse_in_same_call() { expect_no_lint( diff --git a/crates/jarl-semantic/src/lib.rs b/crates/jarl-semantic/src/lib.rs index 9d8b578a7..e290124ae 100644 --- a/crates/jarl-semantic/src/lib.rs +++ b/crates/jarl-semantic/src/lib.rs @@ -443,12 +443,55 @@ impl<'a> SemanticInfo<'a> { continue; } for (def_scope, def_id) in index.reaching_definitions(scope_id, use_id) { - self.reaching_used.insert((def_scope, def_id)); + self.mark_reaching_definition_used(def_scope, def_id); } } } } + /// Record a definition reached by a real read as used. + /// + /// An NSE assignment (`substitute(x <- 2)`) is quoted code, not an + /// executed assignment, but oak — which doesn't model NSE — still lets it + /// shadow a prior real definition in its dataflow. So a real read after + /// such an assignment resolves to the NSE definition instead of the live + /// binding it actually reads. When that happens, walk back to the nearest + /// preceding real definition of the same symbol and mark it used instead. + fn mark_reaching_definition_used(&mut self, def_scope: ScopeId, def_id: DefinitionId) { + let def = &self.index.definitions(def_scope)[def_id]; + if !self.is_in_nse(def.range()) { + self.reaching_used.insert((def_scope, def_id)); + return; + } + if let Some(real_id) = self.preceding_real_definition(def_scope, def) { + self.reaching_used.insert((def_scope, real_id)); + } + } + + /// The nearest definition of `target`'s symbol in `scope` that starts + /// before `target` and is not itself an NSE (quoted) assignment. + fn preceding_real_definition( + &self, + scope: ScopeId, + target: &Definition, + ) -> Option { + let symbol = target.symbol(); + let cutoff = target.range().start(); + let mut best: Option<(DefinitionId, TextRange)> = None; + for (id, def) in self.index.definitions(scope).iter() { + if def.symbol() != symbol || def.range().start() >= cutoff { + continue; + } + if self.is_in_nse(def.range()) { + continue; + } + if best.is_none_or(|(_, best_range)| def.range().start() > best_range.start()) { + best = Some((id, def.range())); + } + } + best.map(|(id, _)| id) + } + fn is_used_inside_local_body(&self, scope_id: ScopeId, def: &Definition) -> bool { if self.local_body_ranges.is_empty() { return false; From 0cbd60932e3de4f5d2d66a69e06af96d7a11b92a Mon Sep 17 00:00:00 2001 From: etiennebacher Date: Sun, 21 Jun 2026 14:09:46 +0100 Subject: [PATCH 22/39] ensure that arguments of quote-like functions are used --- .../src/lints/base/unused_object/mod.rs | 11 +++++ crates/jarl-semantic/src/lib.rs | 43 +++++++++++++++---- 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/crates/jarl-core/src/lints/base/unused_object/mod.rs b/crates/jarl-core/src/lints/base/unused_object/mod.rs index 7de59bf28..3caa6c6f4 100644 --- a/crates/jarl-core/src/lints/base/unused_object/mod.rs +++ b/crates/jarl-core/src/lints/base/unused_object/mod.rs @@ -894,6 +894,17 @@ for (i in 1:2) { expect_no_lint("substitute(y <- 1)", "unused_object", None); } + #[test] + fn test_nse_evaluated_argument_counts_as_use() { + // `substitute`'s `env` argument is evaluated, not quoted, so the read + // of `env` keeps the binding alive. + expect_no_lint( + "env <- as.environment(list(x = 1))\nsubstitute(x, env = env)", + "unused_object", + None, + ); + } + #[test] fn test_nse_assignment_does_not_shadow_real_definition() { // The quoted `x <- 2` must not kill the real `x <- 1`; `print(x)` diff --git a/crates/jarl-semantic/src/lib.rs b/crates/jarl-semantic/src/lib.rs index e290124ae..9c0a99be6 100644 --- a/crates/jarl-semantic/src/lib.rs +++ b/crates/jarl-semantic/src/lib.rs @@ -326,19 +326,29 @@ impl<'a> SemanticInfo<'a> { let arg_values: Vec<(Option, RSyntaxNode)> = call_args(call); match name.as_str() { - "quote" | "substitute" | "enquote" | "expression" | "Quote" => { + // Only the quoted `expr` argument is NSE. Other arguments are + // evaluated normally — e.g. `substitute(x, env = env)` reads + // `env` — so their identifiers stay real uses. + "quote" | "substitute" | "Quote" => { + if let Some(expr) = nse_expr_arg(&arg_values) { + self.nse_ranges.push(expr.text_trimmed_range()); + } + } + // `expression(...)` quotes every argument. + "expression" => { for (_, value) in &arg_values { self.nse_ranges.push(value.text_trimmed_range()); } } - // `bquote` quotes its argument, but `.()` unquotes (evaluates) the - // wrapped expression. So the argument is NSE — `bquote(x)` does not - // use `x` — except for identifiers inside `.()`, which are real - // uses: `bquote(.(x))` does use `x`. + // `bquote` quotes its `expr` argument, but `.()` unquotes + // (evaluates) the wrapped expression. So `expr` is NSE — + // `bquote(x)` does not use `x` — except for identifiers inside + // `.()`, which are real uses: `bquote(.(x))` does use `x`. The + // `where`/`splice` arguments are evaluated normally. "bquote" => { - for (_, value) in &arg_values { - self.nse_ranges.push(value.text_trimmed_range()); - self.collect_bquote_unquoted_uses(value); + if let Some(expr) = nse_expr_arg(&arg_values) { + self.nse_ranges.push(expr.text_trimmed_range()); + self.collect_bquote_unquoted_uses(expr); } } "do.call" | "match.fun" | "Recall" | "getFunction" => { @@ -601,6 +611,23 @@ pub fn lhs_range_for_definition(def: &Definition, root: &RSyntaxNode) -> Option< } } +/// The value node of the quoted-expression argument (`expr`) of a quote-like +/// call: the argument named `expr =` if present, otherwise the first +/// positional (unnamed) argument. Other arguments — `substitute`'s `env`, +/// `bquote`'s `where`/`splice` — are evaluated normally, so their reads must +/// not be swallowed as NSE. +fn nse_expr_arg(args: &[(Option, RSyntaxNode)]) -> Option<&RSyntaxNode> { + if let Some((_, value)) = args + .iter() + .find(|(name, _)| name.as_deref() == Some("expr")) + { + return Some(value); + } + args.iter() + .find(|(name, _)| name.is_none()) + .map(|(_, value)| value) +} + fn call_name(call: &RCall) -> Option { let func = call.function().ok()?; match func { From e154b24a792e71cc9df859394601dbcaf1604651 Mon Sep 17 00:00:00 2001 From: etiennebacher Date: Sun, 21 Jun 2026 14:14:41 +0100 Subject: [PATCH 23/39] gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 343d63a59..71d381552 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ mypkg test_jarl* mutants* test\.R +testpkg From de8bca45d811c6c57bab9362f685d08fe27b76e6 Mon Sep 17 00:00:00 2001 From: etiennebacher Date: Sun, 21 Jun 2026 15:50:52 +0100 Subject: [PATCH 24/39] try improve perf --- crates/jarl-core/src/db.rs | 54 +++++++++++++++++++++++++++----------- 1 file changed, 39 insertions(+), 15 deletions(-) diff --git a/crates/jarl-core/src/db.rs b/crates/jarl-core/src/db.rs index 89b9c13fd..7cf593d42 100644 --- a/crates/jarl-core/src/db.rs +++ b/crates/jarl-core/src/db.rs @@ -22,6 +22,7 @@ use air_r_parser::RParserOptions; use oak_db::{Db, File, OakDatabase, Package, workspace_files}; use oak_scan::ScanScheduler; use oak_semantic::ScopeId; +use rayon::prelude::*; use oak_semantic::semantic_index::SemanticIndex; use crate::package::find_package_root; @@ -130,32 +131,54 @@ impl AnalysisDb { pub fn cross_file_used_objects(&self) -> HashMap> { let db = self.db(); - // Build a throwaway index per file to enumerate use sites, and collect - // the package's top-level binding names. Only those names can be the - // target of a cross-file read, so probing just their uses skips the - // locals, package functions, and library symbols that dominate a file. - let mut indices: Vec<(File, SemanticIndex)> = Vec::new(); + // Pull each file's source text up front. `source_text` is a salsa + // query, so it needs the (`!Sync`) db; doing it here lets the parse + + // index build below run on the rayon pool, which the resolve_at pass + // can't. + let sources: Vec<(File, String)> = workspace_files(db) + .iter() + .map(|&file| (file, file.source_text(db).clone())) + .collect(); + + // Build a throwaway index per file to enumerate use sites. Parsing and + // index building touch no db state, so they run in parallel, unlike the + // resolve_at pass below. + let indices: Vec<(File, SemanticIndex)> = sources + .par_iter() + .filter_map(|(file, source)| { + let parsed = air_r_parser::parse(source, RParserOptions::default()); + if parsed.has_error() { + return None; + } + let index = + oak_semantic::build_index(&parsed.tree(), oak_semantic::NoopImportsResolver); + Some((*file, index)) + }) + .collect(); + + // The package's top-level binding names. Only those can be the target + // of a cross-file read, so probing just their uses skips the locals, + // package functions, and library symbols that dominate a file. let mut candidates: HashSet = HashSet::new(); - for &file in workspace_files(db) { - let parsed = - air_r_parser::parse(file.source_text(db).as_str(), RParserOptions::default()); - if parsed.has_error() { - continue; - } - let index = - oak_semantic::build_index(&parsed.tree(), oak_semantic::NoopImportsResolver); + for (_, index) in &indices { candidates.extend(index.exports().keys().map(|name| name.to_string())); - indices.push((file, index)); } let file_scope = ScopeId::from(0); let mut used: HashMap> = HashMap::new(); for (file, index) in &indices { + // A name that already resolved to a sibling file is settled: a + // package symbol is read the same way throughout a file, so the + // first hit records the cross-file use and later reads can skip the + // (expensive) cross-file walk in `resolve_at`. Most candidate reads + // are repeated references to the same handful of package helpers, + // so this collapses the dominant cost. + let mut resolved_cross_file: HashSet = HashSet::new(); for scope in index.scope_ids() { let symbols = index.symbols(scope); for (_use_id, use_site) in index.uses(scope).iter() { let name = symbols.symbol(use_site.symbol()).name(); - if !candidates.contains(name) { + if !candidates.contains(name) || resolved_cross_file.contains(name) { continue; } for def in file.resolve_at(db, use_site.range().start()) { @@ -173,6 +196,7 @@ impl AnalysisDb { else { continue; }; + resolved_cross_file.insert(name.to_string()); used.entry(PathBuf::from(crate::fs::relativize_path(&path))) .or_default() .insert(def.name(db).text(db).as_str().to_string()); From 49ea96c945b6e8bac64d8cd4121e97ce8fa57bf9 Mon Sep 17 00:00:00 2001 From: etiennebacher Date: Tue, 23 Jun 2026 22:57:43 +0100 Subject: [PATCH 25/39] try more perf improvement --- crates/jarl-core/src/db.rs | 150 ++++++++++++++++++++++--------------- 1 file changed, 89 insertions(+), 61 deletions(-) diff --git a/crates/jarl-core/src/db.rs b/crates/jarl-core/src/db.rs index 7cf593d42..2b1c41e44 100644 --- a/crates/jarl-core/src/db.rs +++ b/crates/jarl-core/src/db.rs @@ -21,9 +21,8 @@ use aether_path::FilePath; use air_r_parser::RParserOptions; use oak_db::{Db, File, OakDatabase, Package, workspace_files}; use oak_scan::ScanScheduler; -use oak_semantic::ScopeId; -use rayon::prelude::*; use oak_semantic::semantic_index::SemanticIndex; +use rayon::prelude::*; use crate::package::find_package_root; @@ -45,6 +44,15 @@ pub struct AnalysisDb { db: OakDatabase, } +/// One file's contribution to cross-file resolution: the names it binds at +/// top level, and the names it reads *freely* — without binding them anywhere +/// in the file, so they reference the package namespace. +struct FileUses { + path: PathBuf, + top_defs: Vec, + free_uses: HashSet, +} + impl AnalysisDb { /// Scan the package roots covering `paths` into a fresh database. /// @@ -121,86 +129,78 @@ impl AnalysisDb { /// /// A package's R files share one namespace, so a top-level binding defined /// in one file and read in another is used even when its own file never - /// reads it. We enumerate every use in every scanned file and resolve it - /// with oak's cross-file [`File::resolve_at`]: a use that binds to a - /// file-scope definition in a *different* file is a cross-file read of that - /// package-level object. The defining file's name is recorded against the - /// read so the lint can suppress it. + /// reads it. For every file we collect, from its per-file index, the names + /// it defines at top level and the names it reads *freely* — uses with no + /// binding anywhere in the file, which therefore reference the package + /// namespace (this is the same `reaching_definitions().is_empty()` test + /// oak's `resolve_at` uses to decide local-vs-cross-file). A top-level + /// definition is cross-file-used when another file reads its name freely. + /// + /// This avoids per-use `File::resolve_at`, which has to run on a single + /// thread because the salsa db is `!Sync`. The index work here is db-free + /// and runs on the rayon pool; only the cheap final merge is sequential. /// /// Keyed by relativized file path to match the lint's per-file lookup. pub fn cross_file_used_objects(&self) -> HashMap> { let db = self.db(); - // Pull each file's source text up front. `source_text` is a salsa - // query, so it needs the (`!Sync`) db; doing it here lets the parse + - // index build below run on the rayon pool, which the resolve_at pass - // can't. - let sources: Vec<(File, String)> = workspace_files(db) + // Pull each file's path + source up front: both are salsa queries that + // need the (`!Sync`) db, so doing it here lets the parse + index pass + // below run in parallel. + let sources: Vec<(PathBuf, String)> = workspace_files(db) .iter() - .map(|&file| (file, file.source_text(db).clone())) + .filter_map(|&file| { + let path = file.path(db).as_path().map(|p| p.as_std_path().to_path_buf())?; + let rel = PathBuf::from(crate::fs::relativize_path(&path)); + Some((rel, file.source_text(db).clone())) + }) .collect(); - // Build a throwaway index per file to enumerate use sites. Parsing and - // index building touch no db state, so they run in parallel, unlike the - // resolve_at pass below. - let indices: Vec<(File, SemanticIndex)> = sources + // Per file, in parallel: parse, build a throwaway index, and read off + // the file's top-level definitions and its free uses. No db access, so + // this is the rayon-friendly bulk of the work. + let per_file: Vec = sources .par_iter() - .filter_map(|(file, source)| { + .filter_map(|(path, source)| { let parsed = air_r_parser::parse(source, RParserOptions::default()); if parsed.has_error() { return None; } let index = oak_semantic::build_index(&parsed.tree(), oak_semantic::NoopImportsResolver); - Some((*file, index)) + Some(collect_file_uses(path.clone(), &index)) }) .collect(); - // The package's top-level binding names. Only those can be the target - // of a cross-file read, so probing just their uses skips the locals, - // package functions, and library symbols that dominate a file. - let mut candidates: HashSet = HashSet::new(); - for (_, index) in &indices { - candidates.extend(index.exports().keys().map(|name| name.to_string())); + // Only a name defined at top level somewhere in the package can be the + // target of a cross-file read, so we ignore free uses of locals, + // library symbols, and base functions. + let candidates: HashSet<&str> = per_file + .iter() + .flat_map(|file| file.top_defs.iter().map(String::as_str)) + .collect(); + + // For each candidate name, the files that read it freely. + let mut readers_of: HashMap<&str, Vec<&PathBuf>> = HashMap::new(); + for file in &per_file { + for name in &file.free_uses { + if candidates.contains(name.as_str()) { + readers_of.entry(name).or_default().push(&file.path); + } + } } - let file_scope = ScopeId::from(0); + // A top-level definition is used when some *other* file reads its name. let mut used: HashMap> = HashMap::new(); - for (file, index) in &indices { - // A name that already resolved to a sibling file is settled: a - // package symbol is read the same way throughout a file, so the - // first hit records the cross-file use and later reads can skip the - // (expensive) cross-file walk in `resolve_at`. Most candidate reads - // are repeated references to the same handful of package helpers, - // so this collapses the dominant cost. - let mut resolved_cross_file: HashSet = HashSet::new(); - for scope in index.scope_ids() { - let symbols = index.symbols(scope); - for (_use_id, use_site) in index.uses(scope).iter() { - let name = symbols.symbol(use_site.symbol()).name(); - if !candidates.contains(name) || resolved_cross_file.contains(name) { - continue; - } - for def in file.resolve_at(db, use_site.range().start()) { - // Only top-level bindings are shared across the package, - // and a read in the binding's own file is already - // handled by the per-file analysis. - if def.scope(db) != file_scope || def.file(db) == *file { - continue; - } - let Some(path) = def - .file(db) - .path(db) - .as_path() - .map(|p| p.as_std_path().to_path_buf()) - else { - continue; - }; - resolved_cross_file.insert(name.to_string()); - used.entry(PathBuf::from(crate::fs::relativize_path(&path))) - .or_default() - .insert(def.name(db).text(db).as_str().to_string()); - } + for file in &per_file { + for name in &file.top_defs { + let Some(readers) = readers_of.get(name.as_str()) else { + continue; + }; + if readers.iter().any(|reader| **reader != file.path) { + used.entry(file.path.clone()) + .or_default() + .insert(name.clone()); } } } @@ -208,6 +208,34 @@ impl AnalysisDb { } } +/// Collect a file's top-level definitions and its free uses from its index. +/// +/// A use is *free* when no definition reaches it within the file — the same +/// `reaching_definitions().is_empty()` test oak's `resolve_at` uses before +/// falling back to cross-file resolution. Reaching definitions already fold in +/// enclosing-scope captures, so a closure reading an outer local counts as +/// bound, not free. +fn collect_file_uses(path: PathBuf, index: &SemanticIndex) -> FileUses { + let top_defs: Vec = index + .exports() + .keys() + .map(|name| name.to_string()) + .collect(); + + let mut free_uses: HashSet = HashSet::new(); + for scope in index.scope_ids() { + let symbols = index.symbols(scope); + for (use_id, use_site) in index.uses(scope).iter() { + if index.reaching_definitions(scope, use_id).next().is_some() { + continue; + } + free_uses.insert(symbols.symbol(use_site.symbol()).name().to_string()); + } + } + + FileUses { path, top_defs, free_uses } +} + /// Resolve a list of database [`File`]s to their filesystem paths, dropping /// any whose URL has no filesystem path (e.g. virtual documents). fn file_paths(db: &dyn Db, files: &[File]) -> Vec { From bd8b59c52e2e61334db83034613154869e309d40 Mon Sep 17 00:00:00 2001 From: etiennebacher Date: Tue, 23 Jun 2026 23:56:45 +0100 Subject: [PATCH 26/39] more perf --- crates/jarl-core/src/check.rs | 40 ++++++++++++++++------- crates/jarl-core/src/db.rs | 58 +++++++++++++++++++++++++-------- crates/jarl-core/src/package.rs | 17 +++++++--- crates/jarl-lsp/src/lint.rs | 3 ++ 4 files changed, 88 insertions(+), 30 deletions(-) diff --git a/crates/jarl-core/src/check.rs b/crates/jarl-core/src/check.rs index 40044515f..0bab90ea5 100644 --- a/crates/jarl-core/src/check.rs +++ b/crates/jarl-core/src/check.rs @@ -126,6 +126,8 @@ pub fn lint_only( &pkg, &pkg_contexts, &file_pkg_info, + // lint-only: on-disk contents match the cached index, so reuse it. + true, ) .with_context(|| format!("Failed to get checks for file: {path}"))?; @@ -167,6 +169,9 @@ pub fn lint_fix( &pkg, &pkg_contexts, &file_pkg_info, + // fix mode rewrites the file between iterations, so the cached + // index is stale; rebuild from the in-memory contents. + false, ) .with_context(|| format!("Failed to get checks for file: {path}",))?; @@ -203,6 +208,7 @@ pub fn get_checks( pkg: &PackageAnalysis, pkg_contexts: &HashMap, file_pkg_info: &HashMap, + use_cached_index: bool, ) -> Result> { if crate::fs::has_rmd_extension(file) { return get_checks_rmd(contents, file, config); @@ -230,22 +236,34 @@ pub fn get_checks( // the complementary "names read by sourced files" path is still handled // inside `SemanticInfo`. // - // The index is built here, in the parallel per-file pass, rather than - // pulled from the shared `AnalysisDb`: oak's salsa database is `Send` but - // not `Sync`, so it can't be borrowed across rayon worker threads. Cross- - // file analysis that needs the database runs in the sequential pre-passes - // (`summarize_package_info`, `make_package_analysis`) instead. - let semantic = oak_semantic::build_index( - &parsed.tree(), - jarl_semantic::JarlImportsResolver::new(file), - ); + // When `unused_object` runs, the cross-file pre-pass already built this + // file's index (with the same resolver) and stored it in `pkg.file_indices`; + // reuse it rather than rebuilding. The pre-pass reads from disk, so the + // cache is only valid in lint-only mode — fix mode rewrites the file + // between passes, so it always rebuilds from the in-memory contents. + // + // Building (when not cached) happens here, in the parallel per-file pass, + // rather than via the shared `AnalysisDb`: oak's salsa database is `Send` + // but not `Sync`, so it can't be borrowed across rayon worker threads. + let owned_semantic; + let semantic: &oak_semantic::semantic_index::SemanticIndex = + match use_cached_index.then(|| pkg.file_indices.get(file)).flatten() { + Some(cached) => cached, + None => { + owned_semantic = oak_semantic::build_index( + &parsed.tree(), + jarl_semantic::JarlImportsResolver::new(file), + ); + &owned_semantic + } + }; checker.file_path = file.to_path_buf(); // Wire up package context for package-specific rules. get_package_info( &mut checker, file, - &semantic, + semantic, config, pkg_contexts, file_pkg_info, @@ -287,7 +305,7 @@ pub fn get_checks( syntax, &mut checker, &package_file, - Some(&semantic), + Some(semantic), )?; // Some rules have a fix available in their implementation but do not have diff --git a/crates/jarl-core/src/db.rs b/crates/jarl-core/src/db.rs index 2b1c41e44..df75a29ce 100644 --- a/crates/jarl-core/src/db.rs +++ b/crates/jarl-core/src/db.rs @@ -16,6 +16,7 @@ use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; +use std::sync::Arc; use aether_path::FilePath; use air_r_parser::RParserOptions; @@ -53,6 +54,17 @@ struct FileUses { free_uses: HashSet, } +/// Result of the package-wide cross-file pass. +#[derive(Default)] +pub struct CrossFileAnalysis { + /// Per defining-file (relativized path): top-level object names read from + /// another file in the same package. + pub used: HashMap>, + /// Per-file (relativized path) semantic index, built once here and shared + /// with the parallel lint pass so it isn't rebuilt. + pub indices: HashMap>, +} + impl AnalysisDb { /// Scan the package roots covering `paths` into a fresh database. /// @@ -140,8 +152,14 @@ impl AnalysisDb { /// thread because the salsa db is `!Sync`. The index work here is db-free /// and runs on the rayon pool; only the cheap final merge is sequential. /// + /// The per-file indices built here are returned alongside the use map: the + /// parallel lint pass reuses them via [`PackageAnalysis::file_indices`] + /// instead of rebuilding each file's index a second time. They're built + /// with the real [`jarl_semantic::JarlImportsResolver`] (not the no-op one) + /// so they're identical to what the lint pass would build. + /// /// Keyed by relativized file path to match the lint's per-file lookup. - pub fn cross_file_used_objects(&self) -> HashMap> { + pub fn cross_file_used_objects(&self) -> CrossFileAnalysis { let db = self.db(); // Pull each file's path + source up front: both are salsa queries that @@ -150,39 +168,46 @@ impl AnalysisDb { let sources: Vec<(PathBuf, String)> = workspace_files(db) .iter() .filter_map(|&file| { - let path = file.path(db).as_path().map(|p| p.as_std_path().to_path_buf())?; + let path = file + .path(db) + .as_path() + .map(|p| p.as_std_path().to_path_buf())?; let rel = PathBuf::from(crate::fs::relativize_path(&path)); Some((rel, file.source_text(db).clone())) }) .collect(); - // Per file, in parallel: parse, build a throwaway index, and read off - // the file's top-level definitions and its free uses. No db access, so - // this is the rayon-friendly bulk of the work. - let per_file: Vec = sources + // Per file, in parallel: parse, build the index, and read off the + // file's top-level definitions and its free uses. No db access, so this + // is the rayon-friendly bulk of the work. The index is kept (shared + // with the lint pass), so building it here is not throwaway work. + let built: Vec<(Arc, FileUses)> = sources .par_iter() .filter_map(|(path, source)| { let parsed = air_r_parser::parse(source, RParserOptions::default()); if parsed.has_error() { return None; } - let index = - oak_semantic::build_index(&parsed.tree(), oak_semantic::NoopImportsResolver); - Some(collect_file_uses(path.clone(), &index)) + let index = oak_semantic::build_index( + &parsed.tree(), + jarl_semantic::JarlImportsResolver::new(path.clone()), + ); + let uses = collect_file_uses(path.clone(), &index); + Some((Arc::new(index), uses)) }) .collect(); // Only a name defined at top level somewhere in the package can be the // target of a cross-file read, so we ignore free uses of locals, // library symbols, and base functions. - let candidates: HashSet<&str> = per_file + let candidates: HashSet<&str> = built .iter() - .flat_map(|file| file.top_defs.iter().map(String::as_str)) + .flat_map(|(_, file)| file.top_defs.iter().map(String::as_str)) .collect(); // For each candidate name, the files that read it freely. let mut readers_of: HashMap<&str, Vec<&PathBuf>> = HashMap::new(); - for file in &per_file { + for (_, file) in &built { for name in &file.free_uses { if candidates.contains(name.as_str()) { readers_of.entry(name).or_default().push(&file.path); @@ -192,7 +217,7 @@ impl AnalysisDb { // A top-level definition is used when some *other* file reads its name. let mut used: HashMap> = HashMap::new(); - for file in &per_file { + for (_, file) in &built { for name in &file.top_defs { let Some(readers) = readers_of.get(name.as_str()) else { continue; @@ -204,7 +229,12 @@ impl AnalysisDb { } } } - used + + let indices = built + .iter() + .map(|(index, file)| (file.path.clone(), Arc::clone(index))) + .collect(); + CrossFileAnalysis { used, indices } } } diff --git a/crates/jarl-core/src/package.rs b/crates/jarl-core/src/package.rs index af159de5a..1d8056548 100644 --- a/crates/jarl-core/src/package.rs +++ b/crates/jarl-core/src/package.rs @@ -1,7 +1,9 @@ use biome_rowan::TextRange; +use oak_semantic::semantic_index::SemanticIndex; use rayon::prelude::*; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; +use std::sync::Arc; use crate::checker::DEFAULT_PACKAGES; use crate::config::Config; @@ -87,6 +89,10 @@ pub struct PackageAnalysis { /// consults this to avoid flagging such cross-file-used objects. Computed /// from oak's cross-file name resolution (`File::resolve_at`). pub cross_file_used: HashMap>, + /// Per-file semantic index built during the cross-file pass, keyed by + /// relativized path. The parallel lint pass reuses these instead of + /// rebuilding each file's index. Empty unless `unused_object` runs. + pub file_indices: HashMap>, } /// The entries of [`PackageAnalysis`] for a single file. Bundled so the @@ -356,18 +362,19 @@ pub fn make_package_analysis( HashMap::new() }; - // Reuse the database scanned above: resolve every use across the package's - // files to find top-level objects read from another file. - let cross_file_used = if check_unused_object { + // Reuse the database scanned above: find top-level objects read from + // another file, and keep the per-file indices for the lint pass to reuse. + let cross_file = if check_unused_object { db.cross_file_used_objects() } else { - HashMap::new() + crate::db::CrossFileAnalysis::default() }; PackageAnalysis { duplicate_assignments, unused_functions, - cross_file_used, + cross_file_used: cross_file.used, + file_indices: cross_file.indices, } } diff --git a/crates/jarl-lsp/src/lint.rs b/crates/jarl-lsp/src/lint.rs index 3ec20edbe..c999ef493 100644 --- a/crates/jarl-lsp/src/lint.rs +++ b/crates/jarl-lsp/src/lint.rs @@ -185,6 +185,9 @@ fn run_jarl_linting( &pkg, &pkg_contexts, &file_pkg_info, + // The buffer being linted is the in-memory editor content, which can + // differ from the on-disk content the cached index was built from. + false, )?; // Hide unused_function diagnostics when the package-wide count exceeds From 2ad7e364d99f140a3f132e023ff426e8f5fd7235 Mon Sep 17 00:00:00 2001 From: etiennebacher Date: Wed, 24 Jun 2026 19:22:26 +0100 Subject: [PATCH 27/39] more --- crates/jarl-core/src/check.rs | 32 ++++++++++++++++---------------- crates/jarl-core/src/db.rs | 30 +++++++++++++++++------------- 2 files changed, 33 insertions(+), 29 deletions(-) diff --git a/crates/jarl-core/src/check.rs b/crates/jarl-core/src/check.rs index 0bab90ea5..f6be0cf86 100644 --- a/crates/jarl-core/src/check.rs +++ b/crates/jarl-core/src/check.rs @@ -159,9 +159,6 @@ pub fn lint_fix( return Ok(Vec::new()); } - // Fix mode rewrites the file between iterations, so the on-disk - // contents drift from the database snapshot. Pass `None` to rebuild - // the index from the in-memory `contents` rather than the stale cache. checks = get_checks( &contents, &PathBuf::from(&path), @@ -169,8 +166,9 @@ pub fn lint_fix( &pkg, &pkg_contexts, &file_pkg_info, - // fix mode rewrites the file between iterations, so the cached - // index is stale; rebuild from the in-memory contents. + // Fix mode rewrites the file between iterations, so the on-disk + // contents (and the index the pre-pass cached from them) drift from + // the in-memory `contents`; rebuild the index rather than reuse it. false, ) .with_context(|| format!("Failed to get checks for file: {path}",))?; @@ -246,17 +244,19 @@ pub fn get_checks( // rather than via the shared `AnalysisDb`: oak's salsa database is `Send` // but not `Sync`, so it can't be borrowed across rayon worker threads. let owned_semantic; - let semantic: &oak_semantic::semantic_index::SemanticIndex = - match use_cached_index.then(|| pkg.file_indices.get(file)).flatten() { - Some(cached) => cached, - None => { - owned_semantic = oak_semantic::build_index( - &parsed.tree(), - jarl_semantic::JarlImportsResolver::new(file), - ); - &owned_semantic - } - }; + let semantic: &oak_semantic::semantic_index::SemanticIndex = match use_cached_index + .then(|| pkg.file_indices.get(file)) + .flatten() + { + Some(cached) => cached, + None => { + owned_semantic = oak_semantic::build_index( + &parsed.tree(), + jarl_semantic::JarlImportsResolver::new(file), + ); + &owned_semantic + } + }; checker.file_path = file.to_path_buf(); // Wire up package context for package-specific rules. diff --git a/crates/jarl-core/src/db.rs b/crates/jarl-core/src/db.rs index df75a29ce..5fdd5b7e9 100644 --- a/crates/jarl-core/src/db.rs +++ b/crates/jarl-core/src/db.rs @@ -162,29 +162,33 @@ impl AnalysisDb { pub fn cross_file_used_objects(&self) -> CrossFileAnalysis { let db = self.db(); - // Pull each file's path + source up front: both are salsa queries that - // need the (`!Sync`) db, so doing it here lets the parse + index pass - // below run in parallel. - let sources: Vec<(PathBuf, String)> = workspace_files(db) + // Collect just the paths up front. `path()` is a salsa query that needs + // the (`!Sync`) db, but it touches no disk, so this sequential loop is + // cheap. Reading the file contents — the part that scales with file + // count — is deferred to the parallel pass below. + let paths: Vec = workspace_files(db) .iter() .filter_map(|&file| { let path = file .path(db) .as_path() .map(|p| p.as_std_path().to_path_buf())?; - let rel = PathBuf::from(crate::fs::relativize_path(&path)); - Some((rel, file.source_text(db).clone())) + Some(PathBuf::from(crate::fs::relativize_path(&path))) }) .collect(); - // Per file, in parallel: parse, build the index, and read off the - // file's top-level definitions and its free uses. No db access, so this - // is the rayon-friendly bulk of the work. The index is kept (shared - // with the lint pass), so building it here is not throwaway work. - let built: Vec<(Arc, FileUses)> = sources + // Per file, in parallel: read the source, parse, build the index, and + // read off the file's top-level definitions and its free uses. None of + // this needs the db, so it's the rayon-friendly bulk of the work. The + // index is kept (shared with the lint pass), so building it here is not + // throwaway work. Reading from disk here (rather than via the db's + // `source_text`) is what lets the read run in parallel; in the one-shot + // CLI the disk is the source of truth, so the two are equivalent. + let built: Vec<(Arc, FileUses)> = paths .par_iter() - .filter_map(|(path, source)| { - let parsed = air_r_parser::parse(source, RParserOptions::default()); + .filter_map(|path| { + let source = std::fs::read_to_string(path).ok()?; + let parsed = air_r_parser::parse(&source, RParserOptions::default()); if parsed.has_error() { return None; } From 06fb4affc5dc58ea9b8526f0b6bdde58741fdbcd Mon Sep 17 00:00:00 2001 From: etiennebacher Date: Wed, 24 Jun 2026 23:23:48 +0100 Subject: [PATCH 28/39] ignore assignment in alist() --- crates/jarl-core/src/lints/base/unused_object/mod.rs | 8 ++++++++ crates/jarl-semantic/src/lib.rs | 6 ++++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/crates/jarl-core/src/lints/base/unused_object/mod.rs b/crates/jarl-core/src/lints/base/unused_object/mod.rs index 3caa6c6f4..414a07f46 100644 --- a/crates/jarl-core/src/lints/base/unused_object/mod.rs +++ b/crates/jarl-core/src/lints/base/unused_object/mod.rs @@ -894,6 +894,14 @@ for (i in 1:2) { expect_no_lint("substitute(y <- 1)", "unused_object", None); } + #[test] + fn test_assignment_inside_alist_is_not_definition() { + // `alist()` stores its arguments unevaluated (as if describing + // function arguments), so `x <- 1` is captured code, not a real + // assignment of `x`. + expect_no_lint("alist(x <- 1)", "unused_object", None); + } + #[test] fn test_nse_evaluated_argument_counts_as_use() { // `substitute`'s `env` argument is evaluated, not quoted, so the read diff --git a/crates/jarl-semantic/src/lib.rs b/crates/jarl-semantic/src/lib.rs index 9c0a99be6..2f875c284 100644 --- a/crates/jarl-semantic/src/lib.rs +++ b/crates/jarl-semantic/src/lib.rs @@ -334,8 +334,10 @@ impl<'a> SemanticInfo<'a> { self.nse_ranges.push(expr.text_trimmed_range()); } } - // `expression(...)` quotes every argument. - "expression" => { + // `expression(...)` and `alist(...)` quote every argument: their + // values are stored unevaluated, so an assignment like + // `alist(x <- 1)` is captured code, not a real definition of `x`. + "expression" | "alist" => { for (_, value) in &arg_values { self.nse_ranges.push(value.text_trimmed_range()); } From baffa0e824eeba294d3ee268d7046a5f3bbc77e3 Mon Sep 17 00:00:00 2001 From: etiennebacher Date: Wed, 24 Jun 2026 23:32:39 +0100 Subject: [PATCH 29/39] extra test for alist --- .../jarl-core/src/lints/base/unused_object/mod.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/jarl-core/src/lints/base/unused_object/mod.rs b/crates/jarl-core/src/lints/base/unused_object/mod.rs index 414a07f46..cd20b312d 100644 --- a/crates/jarl-core/src/lints/base/unused_object/mod.rs +++ b/crates/jarl-core/src/lints/base/unused_object/mod.rs @@ -902,6 +902,19 @@ for (i in 1:2) { expect_no_lint("alist(x <- 1)", "unused_object", None); } + #[test] + fn test_mention_inside_alist_is_not_used() { + assert_snapshot!(snapshot_lint("x <- 1\nalist(x)"), @" + warning: unused_object + --> :1:1 + | + 1 | x <- 1 + | - Object `x` is defined but never used. + | + Found 1 error. + "); + } + #[test] fn test_nse_evaluated_argument_counts_as_use() { // `substitute`'s `env` argument is evaluated, not quoted, so the read From ce8e1806bf08caa6fcfa32ed3dc3dcbe825825d1 Mon Sep 17 00:00:00 2001 From: etiennebacher Date: Fri, 26 Jun 2026 10:53:27 +0100 Subject: [PATCH 30/39] fix --- crates/jarl/tests/integration/exclude.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/jarl/tests/integration/exclude.rs b/crates/jarl/tests/integration/exclude.rs index 4fd33f191..73d3b6e51 100644 --- a/crates/jarl/tests/integration/exclude.rs +++ b/crates/jarl/tests/integration/exclude.rs @@ -355,7 +355,7 @@ fn test_cli_exclude_comma_separated() -> anyhow::Result<()> { #[test] fn test_cli_exclude_glob_function_def_524() -> anyhow::Result<()> { let case = CliTest::with_files([ - ("DESCRIPTION", ""), + ("DESCRIPTION", "Package: testpkg\nVersion: 1.0.0\n"), ("R/foo.R", "f <- function() {}\n"), ("R/bar.R", "f <- function() {}\n"), ("R/baz.R", "f <- function() {}\n"), From 517b22d416232a3467126ed821a7fa8194569bcc Mon Sep 17 00:00:00 2001 From: etiennebacher Date: Fri, 26 Jun 2026 11:16:54 +0100 Subject: [PATCH 31/39] handle glue calls --- crates/jarl-core/src/lints/base/glue/glue.rs | 54 +------ .../src/lints/base/unused_object/mod.rs | 62 ++++++++ crates/jarl-semantic/src/lib.rs | 144 ++++++++++++++---- crates/jarl-semantic/src/strings.rs | 61 ++++++++ 4 files changed, 238 insertions(+), 83 deletions(-) create mode 100644 crates/jarl-semantic/src/strings.rs diff --git a/crates/jarl-core/src/lints/base/glue/glue.rs b/crates/jarl-core/src/lints/base/glue/glue.rs index e9a5440ca..c9c041207 100644 --- a/crates/jarl-core/src/lints/base/glue/glue.rs +++ b/crates/jarl-core/src/lints/base/glue/glue.rs @@ -5,6 +5,7 @@ use crate::utils::{ use crate::utils_ast::AstNodeExt; use air_r_syntax::*; use biome_rowan::AstNode; +use jarl_semantic::strings::get_string_literal_contents; /// Version added: 0.6.0 /// @@ -144,59 +145,6 @@ fn get_named_string_arg_text(args: &RArgumentList, name: &str) -> anyhow::Result )) } -/// Parse string literal content from its raw token text (including quotes). -/// Handles both standard strings ("abc" or 'abc') and raw strings (r"(abc)" or R'-[abc]-'). -/// Returns the unquoted content as a String if parsing succeeds, None otherwise. -fn get_string_literal_contents(text: &str) -> Option { - parse_standard_string(text) - .or_else(|| parse_raw_string(text)) - .map(|content| content.to_string()) -} - -/// Parse a standard string literal: "content" or 'content'. -/// Returns the unquoted content if the string has matching quotes, None otherwise. -fn parse_standard_string(text: &str) -> Option<&str> { - let quote = text.chars().next()?; - if quote != '"' && quote != '\'' { - return None; - } - - let content = text.strip_prefix(quote)?; - content.strip_suffix(quote) -} - -/// Parse a raw string literal: r"(content)", r'-[content]-', etc. (R v4.0+) -/// Handles dashes before the delimiter to avoid early termination. -/// Returns the content between delimiters if parsing succeeds, None otherwise. -fn parse_raw_string(text: &str) -> Option<&str> { - let raw_prefix = text.chars().next()?; - if raw_prefix != 'r' && raw_prefix != 'R' { - return None; - } - - let rest = text.strip_prefix(raw_prefix)?; - let quote = rest.chars().next()?; - if quote != '"' && quote != '\'' { - return None; - } - - let rest = rest.strip_prefix(quote)?; - let after_dashes = rest.trim_start_matches('-'); - let leading_dashes = &rest[..rest.len() - after_dashes.len()]; - - let open_brace = after_dashes.chars().next()?; - let close_brace = match open_brace { - '(' => ')', - '[' => ']', - '{' => '}', - _ => return None, - }; - - let body_and_suffix = after_dashes.strip_prefix(open_brace)?; - let expected_closing_fence = format!("{}{}{}", close_brace, leading_dashes, quote); - body_and_suffix.strip_suffix(&expected_closing_fence) -} - fn has_incomplete_delimiters(text: &str, open: &str, close: &str) -> bool { if open.is_empty() || close.is_empty() { return false; diff --git a/crates/jarl-core/src/lints/base/unused_object/mod.rs b/crates/jarl-core/src/lints/base/unused_object/mod.rs index cd20b312d..f8a4ee189 100644 --- a/crates/jarl-core/src/lints/base/unused_object/mod.rs +++ b/crates/jarl-core/src/lints/base/unused_object/mod.rs @@ -172,6 +172,68 @@ mod tests { ); } + #[test] + fn test_no_lint_glue_basic() { + expect_no_lint("x <- 1\nglue(\"this is {x}\")", "unused_object", None); + } + + #[test] + fn test_no_lint_glue_custom_delimiters() { + expect_no_lint( + "x <- 1\nglue(\"\", .open = \"<\", .close = \">\")", + "unused_object", + None, + ); + } + + #[test] + fn test_no_lint_glue_custom_multichar_delimiters() { + expect_no_lint( + "x <- 1\nglue(\"<>\", .open = \"<<\", .close = \">>\")", + "unused_object", + None, + ); + } + + #[test] + fn test_no_lint_glue_custom_delimiters_raw_string() { + expect_no_lint( + "x <- 1\nglue(r\"([x])\", .open = \"[\", .close = \"]\")", + "unused_object", + None, + ); + } + + #[test] + fn test_no_lint_str_glue_default_delimiters() { + expect_no_lint("x <- 1\nstr_glue(\"{x}\")", "unused_object", None); + } + + #[test] + fn test_no_lint_glue_sql_default_delimiters() { + expect_no_lint( + "col <- 1\nglue_sql(\"SELECT {col}\", .con = con)", + "unused_object", + None, + ); + } + + #[test] + fn test_lint_glue_custom_delimiters_unrelated_object() { + assert_snapshot!( + snapshot_lint("x <- 1\nglue(\"[a]\", .open = \"[\", .close = \"]\")"), + @r#" + warning: unused_object + --> :1:1 + | + 1 | x <- 1 + | - Object `x` is defined but never used. + | + Found 1 error. + "# + ); + } + #[test] fn test_no_lint_returned_by_function() { expect_no_lint("f <- function() {\n x <- 1\n x\n}", "unused_object", None); diff --git a/crates/jarl-semantic/src/lib.rs b/crates/jarl-semantic/src/lib.rs index 2f875c284..a2e1193ad 100644 --- a/crates/jarl-semantic/src/lib.rs +++ b/crates/jarl-semantic/src/lib.rs @@ -10,6 +10,8 @@ //! `info.is_definition_used(scope, def_id, def)` rather than walking the //! semantic index themselves. +pub mod strings; + use std::collections::HashSet; use air_r_parser::RParserOptions; @@ -212,39 +214,49 @@ impl<'a> SemanticInfo<'a> { let Some(token) = node.first_token() else { return; }; - let text = token.text_trimmed(); - let bytes = text.as_bytes(); - let mut i = 0; - while i < bytes.len() { - // glue's `{{` / `}}` are escaped literals, not interpolations. - if bytes[i] == b'{' && bytes.get(i + 1) == Some(&b'{') { - i += 2; + // Scanned with the default glue delimiters regardless of the wrapping + // call: any `{x}` in any string keeps `x` alive. Calls that override + // the delimiters via `.open`/`.close` are handled separately in + // `collect_custom_glue_interpolation`. + for segment in scan_interpolation_segments(token.text_trimmed(), "{", "}") { + self.collect_identifiers_in_interpolation(segment); + } + } + + /// glue-family calls can override the interpolation delimiters with + /// `.open` / `.close` (e.g. `glue("", .open = "<", .close = ">")`). The + /// default-`{}` scan in [`Self::collect_string_interpolation`] can't see + /// those, so when a call sets custom delimiters, rescan its unnamed string + /// arguments with them and record the identifiers as synthetic uses. + /// + /// Operates on the *unquoted* string contents, not the raw token text: a + /// custom delimiter like `(`/`)` would otherwise collide with the + /// `r"(...)"` raw-string wrapper. + fn collect_custom_glue_interpolation(&mut self, args: &[(Option, RSyntaxNode)]) { + let open = named_string_arg(args, ".open"); + let close = named_string_arg(args, ".close"); + // Nothing to do unless a delimiter is actually customised; the default + // case is already covered by `collect_string_interpolation`. + if open.is_none() && close.is_none() { + return; + } + let open = open.unwrap_or_else(|| "{".to_string()); + let close = close.unwrap_or_else(|| "}".to_string()); + if open == "{" && close == "}" { + return; + } + for (name, value) in args { + if name.is_some() || value.kind() != RSyntaxKind::R_STRING_VALUE { continue; } - if bytes[i] == b'}' && bytes.get(i + 1) == Some(&b'}') { - i += 2; + let Some(token) = value.first_token() else { continue; - } - if bytes[i] == b'{' { - let start = i + 1; - let mut depth = 1usize; - let mut end = start; - while end < bytes.len() && depth > 0 { - match bytes[end] { - b'{' => depth += 1, - b'}' => depth -= 1, - _ => {} - } - if depth > 0 { - end += 1; - } - } - if depth == 0 && end > start { - self.collect_identifiers_in_interpolation(&text[start..end]); - } - i = end + 1; - } else { - i += 1; + }; + let Some(content) = strings::get_string_literal_contents(token.text_trimmed()) else { + continue; + }; + for segment in scan_interpolation_segments(&content, &open, &close) { + self.collect_identifiers_in_interpolation(segment); } } } @@ -325,6 +337,8 @@ impl<'a> SemanticInfo<'a> { let arg_values: Vec<(Option, RSyntaxNode)> = call_args(call); + self.collect_custom_glue_interpolation(&arg_values); + match name.as_str() { // Only the quoted `expr` argument is NSE. Other arguments are // evaluated normally — e.g. `substitute(x, env = env)` reads @@ -546,6 +560,76 @@ fn in_any_range(target: TextRange, ranges: &[TextRange]) -> bool { ranges.iter().any(|r| r.contains_range(target)) } +/// Extract glue-style interpolation segments delimited by `open`/`close`. +/// Doubled delimiters (`{{`/`}}` for the default case) are glue escapes and +/// are skipped. Nested delimiters are tracked so `{f({x})}` yields the whole +/// inner expression. Returns the source slices between the outermost +/// delimiter pairs. +fn scan_interpolation_segments<'t>(text: &'t str, open: &str, close: &str) -> Vec<&'t str> { + let mut segments = Vec::new(); + if open.is_empty() || close.is_empty() { + return segments; + } + let escaped_open = format!("{open}{open}"); + let escaped_close = format!("{close}{close}"); + let mut i = 0; + while i < text.len() { + let slice = &text[i..]; + // Doubled delimiters are glue escape sequences for literal characters. + if slice.starts_with(&escaped_open) { + i += escaped_open.len(); + continue; + } + if slice.starts_with(&escaped_close) { + i += escaped_close.len(); + continue; + } + if slice.starts_with(open) { + let start = i + open.len(); + let mut depth = 1usize; + let mut end = start; + while end < text.len() && depth > 0 { + let rest = &text[end..]; + if rest.starts_with(open) { + depth += 1; + end += open.len(); + } else if rest.starts_with(close) { + depth -= 1; + if depth > 0 { + end += close.len(); + } + } else { + end += next_char_len(text, end); + } + } + if depth == 0 && end > start { + segments.push(&text[start..end]); + } + // Skip past the closing delimiter (`end` points at its start). + i = end + close.len(); + } else { + i += next_char_len(text, i); + } + } + segments +} + +/// Byte length of the UTF-8 character starting at `i` (which must be a char +/// boundary). Used to advance scanning without splitting multi-byte chars. +fn next_char_len(text: &str, i: usize) -> usize { + text[i..].chars().next().map_or(1, |c| c.len_utf8()) +} + +/// Unquoted contents of a named string-literal argument (e.g. `.open = "<"`), +/// or `None` if absent or not a string literal. +fn named_string_arg(args: &[(Option, RSyntaxNode)], name: &str) -> Option { + let (_, value) = args.iter().find(|(n, _)| n.as_deref() == Some(name))?; + if value.kind() != RSyntaxKind::R_STRING_VALUE { + return None; + } + strings::get_string_literal_contents(value.first_token()?.text_trimmed()) +} + fn is_member_name(node: &RSyntaxNode) -> bool { let Some(parent) = node.parent() else { return false; diff --git a/crates/jarl-semantic/src/strings.rs b/crates/jarl-semantic/src/strings.rs new file mode 100644 index 000000000..6120ea67f --- /dev/null +++ b/crates/jarl-semantic/src/strings.rs @@ -0,0 +1,61 @@ +//! String-literal parsing shared across jarl crates. +//! +//! Unquotes R string literals — standard (`"x"` / `'x'`) and raw +//! (`r"(x)"`, `R'-[x]-'`, …) — into their contents. oak's +//! `RStringValueExt::string_text` only strips surrounding quotes and does not +//! understand raw strings, so these helpers fill that gap. Reused by the +//! `glue` lint in jarl-core and by the custom-delimiter interpolation pass. + +/// Parse string literal content from its raw token text (including quotes). +/// Handles both standard strings (`"abc"` or `'abc'`) and raw strings +/// (`r"(abc)"` or `R'-[abc]-'`). Returns the unquoted content as a `String` +/// if parsing succeeds, `None` otherwise. +pub fn get_string_literal_contents(text: &str) -> Option { + parse_standard_string(text) + .or_else(|| parse_raw_string(text)) + .map(|content| content.to_string()) +} + +/// Parse a standard string literal: `"content"` or `'content'`. +/// Returns the unquoted content if the string has matching quotes, `None` otherwise. +fn parse_standard_string(text: &str) -> Option<&str> { + let quote = text.chars().next()?; + if quote != '"' && quote != '\'' { + return None; + } + + let content = text.strip_prefix(quote)?; + content.strip_suffix(quote) +} + +/// Parse a raw string literal: `r"(content)"`, `r'-[content]-'`, etc. (R v4.0+). +/// Handles dashes before the delimiter to avoid early termination. +/// Returns the content between delimiters if parsing succeeds, `None` otherwise. +fn parse_raw_string(text: &str) -> Option<&str> { + let raw_prefix = text.chars().next()?; + if raw_prefix != 'r' && raw_prefix != 'R' { + return None; + } + + let rest = text.strip_prefix(raw_prefix)?; + let quote = rest.chars().next()?; + if quote != '"' && quote != '\'' { + return None; + } + + let rest = rest.strip_prefix(quote)?; + let after_dashes = rest.trim_start_matches('-'); + let leading_dashes = &rest[..rest.len() - after_dashes.len()]; + + let open_brace = after_dashes.chars().next()?; + let close_brace = match open_brace { + '(' => ')', + '[' => ']', + '{' => '}', + _ => return None, + }; + + let body_and_suffix = after_dashes.strip_prefix(open_brace)?; + let expected_closing_fence = format!("{}{}{}", close_brace, leading_dashes, quote); + body_and_suffix.strip_suffix(&expected_closing_fence) +} From 800e708ef44a236bad179e07993938ff47278112 Mon Sep 17 00:00:00 2001 From: etiennebacher Date: Fri, 26 Jun 2026 11:43:25 +0100 Subject: [PATCH 32/39] handle cli calls --- .../src/lints/base/unused_object/mod.rs | 66 ++++++++++++++ crates/jarl-semantic/src/lib.rs | 91 +++++++++++++++++++ 2 files changed, 157 insertions(+) diff --git a/crates/jarl-core/src/lints/base/unused_object/mod.rs b/crates/jarl-core/src/lints/base/unused_object/mod.rs index f8a4ee189..4ef518f31 100644 --- a/crates/jarl-core/src/lints/base/unused_object/mod.rs +++ b/crates/jarl-core/src/lints/base/unused_object/mod.rs @@ -234,6 +234,72 @@ mod tests { ); } + #[test] + fn test_no_lint_cli_interpolation() { + expect_no_lint("x <- 1\ncli_abort(\"{x}\")", "unused_object", None); + expect_no_lint("x <- 1\ncli_warn(\"{x}\")", "unused_object", None); + } + + #[test] + fn test_no_lint_cli_markup_with_interpolation() { + expect_no_lint("x <- 1\ncli_abort(\"{.field {x}}\")", "unused_object", None); + } + + #[test] + fn test_no_lint_cli_nested_markup_with_interpolation() { + expect_no_lint( + "x <- 1\ncli_abort(\"{.strong {.emph {x}}}\")", + "unused_object", + None, + ); + } + + #[test] + fn test_no_lint_cli_namespaced() { + expect_no_lint( + "x <- 1\ncli::cli_abort(\"{.val {x}}\")", + "unused_object", + None, + ); + } + + #[test] + fn test_no_lint_cli_bullets_vector() { + expect_no_lint( + "path <- \"f\"\ncli_abort(c(\"Can't find {.file {path}}\", \"i\" = \"check it\"))", + "unused_object", + None, + ); + } + + #[test] + fn test_no_lint_cli_other_families() { + expect_no_lint("x <- 1\ncli_text(\"{.emph {x}}\")", "unused_object", None); + expect_no_lint("x <- 1\ncli_alert_info(\"{x}\")", "unused_object", None); + expect_no_lint( + "x <- 1\nformat_inline(\"{.field {x}}\")", + "unused_object", + None, + ); + } + + #[test] + fn test_lint_cli_markup_literal_text() { + // In `{.field x}`, `x` is literal styled text, not an interpolation. + assert_snapshot!( + snapshot_lint("x <- 1\ncli_abort(\"{.field x}\")"), + @r#" + warning: unused_object + --> :1:1 + | + 1 | x <- 1 + | - Object `x` is defined but never used. + | + Found 1 error. + "# + ); + } + #[test] fn test_no_lint_returned_by_function() { expect_no_lint("f <- function() {\n x <- 1\n x\n}", "unused_object", None); diff --git a/crates/jarl-semantic/src/lib.rs b/crates/jarl-semantic/src/lib.rs index a2e1193ad..d053bac43 100644 --- a/crates/jarl-semantic/src/lib.rs +++ b/crates/jarl-semantic/src/lib.rs @@ -214,6 +214,15 @@ impl<'a> SemanticInfo<'a> { let Some(token) = node.first_token() else { return; }; + // cli's inline markup (`{.field {x}}`) interleaves styling with + // interpolation, so strings inside a cli call need a markup-aware scan + // rather than the plain glue scan. + if node_in_cli_markup_call(node) { + if let Some(content) = strings::get_string_literal_contents(token.text_trimmed()) { + self.collect_cli_interpolation(&content); + } + return; + } // Scanned with the default glue delimiters regardless of the wrapping // call: any `{x}` in any string keeps `x` alive. Calls that override // the delimiters via `.open`/`.close` are handled separately in @@ -223,6 +232,24 @@ impl<'a> SemanticInfo<'a> { } } + /// Collect identifier uses from a cli-formatted string. + /// + /// cli reuses glue's `{...}` interpolation but adds inline markup spans of + /// the form `{.class content}`, where `.class` and the literal `content` + /// are styling — not R code — yet any nested `{...}` inside the content is + /// still interpolated. So `{.field {x}}` uses `x`, but `{.field x}` does + /// not. Markup spans recurse into their content; plain segments are parsed + /// as R code. + fn collect_cli_interpolation(&mut self, content: &str) { + for segment in scan_interpolation_segments(content, "{", "}") { + if let Some(inner) = cli_markup_content(segment) { + self.collect_cli_interpolation(inner); + } else { + self.collect_identifiers_in_interpolation(segment); + } + } + } + /// glue-family calls can override the interpolation delimiters with /// `.open` / `.close` (e.g. `glue("", .open = "<", .close = ">")`). The /// default-`{}` scan in [`Self::collect_string_interpolation`] can't see @@ -630,6 +657,70 @@ fn named_string_arg(args: &[(Option, RSyntaxNode)], name: &str) -> Optio strings::get_string_literal_contents(value.first_token()?.text_trimmed()) } +/// True if `node` sits inside a cli call that glue-interpolates its arguments +/// with inline markup support. Walks all ancestors (not just the immediate +/// call) so message strings nested in a `c(...)` bullets vector still count. +fn node_in_cli_markup_call(node: &RSyntaxNode) -> bool { + node.ancestors().any(|ancestor| { + ancestor.kind() == RSyntaxKind::R_CALL + && ancestor + .cast::() + .and_then(|call| call_name(&call)) + .is_some_and(|name| is_cli_markup_function(&name)) + }) +} + +/// cli functions that glue-interpolate their text arguments with inline markup. +/// Excludes non-interpolating ones (`cli_verbatim`, `cli_code`, +/// `cli_bullets_raw`). Namespaced calls (`cli::cli_abort`) resolve to the bare +/// name via [`call_name`]. +fn is_cli_markup_function(name: &str) -> bool { + matches!( + name, + "cli_abort" + | "cli_warn" + | "cli_inform" + | "cli_alert" + | "cli_alert_success" + | "cli_alert_info" + | "cli_alert_warning" + | "cli_alert_danger" + | "cli_text" + | "cli_h1" + | "cli_h2" + | "cli_h3" + | "cli_li" + | "cli_ul" + | "cli_ol" + | "cli_dl" + | "cli_bullets" + | "cli_par" + | "cli_progress_message" + | "cli_progress_step" + | "format_inline" + | "format_error" + | "format_warning" + | "format_message" + ) +} + +/// If `segment` is a cli inline-markup span (`.class content`), return the +/// `content` part, which is itself glue-interpolated. The leading `.class` and +/// any literal text are styling, not R code. Returns `None` for plain +/// interpolation segments (`x`, `mean(x)`, `.x` with no following space). +fn cli_markup_content(segment: &str) -> Option<&str> { + let rest = segment.strip_prefix('.')?; + let class_len = rest + .find(|c: char| !(c.is_alphanumeric() || c == '_')) + .unwrap_or(rest.len()); + if class_len == 0 { + return None; + } + // A markup span separates the class from its content with whitespace. + let after_class = rest[class_len..].strip_prefix(|c: char| c.is_whitespace())?; + Some(after_class.trim_start()) +} + fn is_member_name(node: &RSyntaxNode) -> bool { let Some(parent) = node.parent() else { return false; From 29b0c2c226fe2aab38a0fd79463c2401c70d24ce Mon Sep 17 00:00:00 2001 From: etiennebacher Date: Fri, 26 Jun 2026 12:35:38 +0100 Subject: [PATCH 33/39] do not report objects actually used in `where` arg of `bquote()` --- .../src/lints/base/unused_object/mod.rs | 14 ++++++++++++++ crates/jarl-semantic/src/lib.rs | 17 ++++++++++++++--- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/crates/jarl-core/src/lints/base/unused_object/mod.rs b/crates/jarl-core/src/lints/base/unused_object/mod.rs index 4ef518f31..ca349c4d9 100644 --- a/crates/jarl-core/src/lints/base/unused_object/mod.rs +++ b/crates/jarl-core/src/lints/base/unused_object/mod.rs @@ -772,6 +772,20 @@ f <- function() { ); } + #[test] + fn test_bquote_where_arg_counts_as_use() { + expect_no_lint( + "env <- as.environment(list(x = 1))\nbquote(.(x), env)", + "unused_object", + None, + ); + expect_no_lint( + "env <- as.environment(list(x = 1))\nbquote(where = env, .(x))", + "unused_object", + None, + ); + } + #[test] fn test_with_assignment_pipe() { // should lint: re-assigned `x` isn't used diff --git a/crates/jarl-semantic/src/lib.rs b/crates/jarl-semantic/src/lib.rs index d053bac43..e40230ebd 100644 --- a/crates/jarl-semantic/src/lib.rs +++ b/crates/jarl-semantic/src/lib.rs @@ -44,7 +44,8 @@ pub struct SemanticInfo<'a> { nse_ranges: Vec, /// Ranges inside an NSE argument that are nonetheless evaluated and so /// carve a hole back out of [`Self::nse_ranges`]: the `.()` operands of - /// `bquote(...)`. A use inside one of these counts as a real use. + /// `bquote(...)`, plus `bquote`'s `where`/`splice` arguments (which are + /// evaluated normally). A use inside one of these counts as a real use. unquote_ranges: Vec, /// Bodies of `local({...})` calls. local_body_ranges: Vec, @@ -387,12 +388,22 @@ impl<'a> SemanticInfo<'a> { // (evaluates) the wrapped expression. So `expr` is NSE — // `bquote(x)` does not use `x` — except for identifiers inside // `.()`, which are real uses: `bquote(.(x))` does use `x`. The - // `where`/`splice` arguments are evaluated normally. + // `where`/`splice` arguments are evaluated normally, so they are + // carved out of any enclosing NSE range — e.g. in + // `substitute(bquote(.(x), env))`, `env` is a real use even though + // the whole `bquote(...)` sits inside `substitute`'s NSE range. "bquote" => { - if let Some(expr) = nse_expr_arg(&arg_values) { + let expr = nse_expr_arg(&arg_values); + if let Some(expr) = expr { self.nse_ranges.push(expr.text_trimmed_range()); self.collect_bquote_unquoted_uses(expr); } + let expr_range = expr.map(|e| e.text_trimmed_range()); + for (_, value) in &arg_values { + if Some(value.text_trimmed_range()) != expr_range { + self.unquote_ranges.push(value.text_trimmed_range()); + } + } } "do.call" | "match.fun" | "Recall" | "getFunction" => { if let Some((_, first)) = arg_values.first() From 777096019b26dae1472d7620e510ed0a64a7340e Mon Sep 17 00:00:00 2001 From: etiennebacher Date: Fri, 26 Jun 2026 12:37:14 +0100 Subject: [PATCH 34/39] trigger --- crates/jarl-core/src/lints/base/unused_object/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/jarl-core/src/lints/base/unused_object/mod.rs b/crates/jarl-core/src/lints/base/unused_object/mod.rs index ca349c4d9..a5612b094 100644 --- a/crates/jarl-core/src/lints/base/unused_object/mod.rs +++ b/crates/jarl-core/src/lints/base/unused_object/mod.rs @@ -779,6 +779,7 @@ f <- function() { "unused_object", None, ); + expect_no_lint( "env <- as.environment(list(x = 1))\nbquote(where = env, .(x))", "unused_object", From 0ac764e1cfaa554a51eea28ed7d7f6ec4d6aee80 Mon Sep 17 00:00:00 2001 From: etiennebacher Date: Wed, 1 Jul 2026 20:49:57 +0100 Subject: [PATCH 35/39] handle infix operators --- .../src/lints/base/unused_object/mod.rs | 19 +++++++++++++++++++ crates/jarl-semantic/src/lib.rs | 9 +++++++++ 2 files changed, 28 insertions(+) diff --git a/crates/jarl-core/src/lints/base/unused_object/mod.rs b/crates/jarl-core/src/lints/base/unused_object/mod.rs index a5612b094..a91b22bcb 100644 --- a/crates/jarl-core/src/lints/base/unused_object/mod.rs +++ b/crates/jarl-core/src/lints/base/unused_object/mod.rs @@ -124,6 +124,25 @@ mod tests { expect_no_lint("f <- function() { x <<- 1 }", "unused_object", None); } + #[test] + fn test_no_lint_custom_operator_used() { + // oak doesn't model `1 %op% 2` as a use of the `%op%` binding, so a + // custom infix operator defined via a non-function RHS would otherwise + // look unused. + expect_no_lint( + "f <- function() {}\n`%op%` <- f\n1 %op% 2", + "unused_object", + None, + ); + } + + #[test] + fn test_lint_custom_operator_never_used() { + // A custom operator defined but never used at a call site is still + // reported. + assert_snapshot!(snapshot_lint("`%op%` <- 42\nprint(1)")); + } + #[test] fn test_no_lint_replacement_function() { expect_no_lint( diff --git a/crates/jarl-semantic/src/lib.rs b/crates/jarl-semantic/src/lib.rs index e40230ebd..ac8b009e8 100644 --- a/crates/jarl-semantic/src/lib.rs +++ b/crates/jarl-semantic/src/lib.rs @@ -343,6 +343,15 @@ impl<'a> SemanticInfo<'a> { return; } + // Custom infix operators (`a %op% b`): oak doesn't model the operator + // as a use of the `%op%` binding, so an operator whose only reference + // is at a call site would look unused. Record the operator name as a + // synthetic use. Only user-defined `%...%` bindings can match; R's + // built-in operators have no local definition to keep alive. + if op_text.starts_with('%') && op_text.ends_with('%') { + self.synthetic_used_names.insert(op_text.to_string()); + } + // Short-circuit operators: `cond || (x <- 2)` may skip the // assignment entirely, so prior defs of `x` should remain alive. // Oak walks linearly and shadows them. Workaround: any LHS From 7f36e29571a1d9bf187a6f6bc70c8192958bcc66 Mon Sep 17 00:00:00 2001 From: etiennebacher Date: Wed, 1 Jul 2026 21:12:56 +0100 Subject: [PATCH 36/39] handle more glue and cli cases --- .../src/lints/base/unused_object/mod.rs | 89 +++++++++++++- crates/jarl-semantic/src/lib.rs | 111 +++++++++++++++--- 2 files changed, 180 insertions(+), 20 deletions(-) diff --git a/crates/jarl-core/src/lints/base/unused_object/mod.rs b/crates/jarl-core/src/lints/base/unused_object/mod.rs index a91b22bcb..2e249f224 100644 --- a/crates/jarl-core/src/lints/base/unused_object/mod.rs +++ b/crates/jarl-core/src/lints/base/unused_object/mod.rs @@ -140,7 +140,15 @@ mod tests { fn test_lint_custom_operator_never_used() { // A custom operator defined but never used at a call site is still // reported. - assert_snapshot!(snapshot_lint("`%op%` <- 42\nprint(1)")); + assert_snapshot!(snapshot_lint("`%op%` <- 42\nprint(1)"), @" + warning: unused_object + --> :1:1 + | + 1 | `%op%` <- 42 + | ------ Object `%op%` is defined but never used. + | + Found 1 error. + "); } #[test] @@ -253,6 +261,56 @@ mod tests { ); } + #[test] + fn test_no_lint_glue_same_char_delimiters() { + // `.open` and `.close` are the same character, so nesting is + // impossible and the closing `|` must not be read as another opener. + expect_no_lint( + " +x <- 1 +glue(\"|x|\", .open = \"|\", .close = \"|\")", + "unused_object", + None, + ); + } + + #[test] + fn test_lint_interpolation_does_not_revive_later_reassignment() { + // The `glue` read resolves to the preceding `foo`; the later, unread + // reassignment of the same name is still reported unused. + assert_snapshot!( + snapshot_lint(" +foo <- \"a\" +glue(\"{foo}\") + +foo <- \"b\""), + @r#" + warning: unused_object + --> :5:1 + | + 5 | foo <- "b" + | --- Object `foo` is defined but never used. + | + Found 1 error. + "# + ); + } + + #[test] + fn test_no_lint_interpolation_captures_enclosing_definition() { + // The interpolated read is a closure capture evaluated later, so the + // top-level `prefix` it reads stays used even though it is defined + // after the function. + expect_no_lint( + " +f <- function() glue(\"{prefix}\") +prefix <- \"info\" +f()", + "unused_object", + None, + ); + } + #[test] fn test_no_lint_cli_interpolation() { expect_no_lint("x <- 1\ncli_abort(\"{x}\")", "unused_object", None); @@ -273,6 +331,35 @@ mod tests { ); } + #[test] + fn test_lint_cli_interpolation_does_not_revive_later_reassignment() { + // Same position-aware resolution as glue: the `cli_abort` markup read + // resolves to the preceding `foo`, so the later reassignment is unused. + assert_snapshot!( + snapshot_lint("foo <- \"a\"\ncli_abort(\"{.field {foo}}\")\n\nfoo <- \"b\""), + @r#" + warning: unused_object + --> :4:1 + | + 4 | foo <- "b" + | --- Object `foo` is defined but never used. + | + Found 1 error. + "# + ); + } + + #[test] + fn test_no_lint_cli_interpolation_captures_enclosing_definition() { + // A cli markup read inside a closure captures the enclosing `prefix` + // even though it is defined after the function. + expect_no_lint( + "f <- function() cli_abort(\"{.field {prefix}}\")\nprefix <- \"info\"\nf()", + "unused_object", + None, + ); + } + #[test] fn test_no_lint_cli_namespaced() { expect_no_lint( diff --git a/crates/jarl-semantic/src/lib.rs b/crates/jarl-semantic/src/lib.rs index ac8b009e8..719b4cf72 100644 --- a/crates/jarl-semantic/src/lib.rs +++ b/crates/jarl-semantic/src/lib.rs @@ -19,7 +19,7 @@ use air_r_syntax::{ AnyRArgumentName, AnyRExpression, RArgument, RArgumentList, RBinaryExpression, RCall, RExtractExpression, RNamespaceExpression, RStringValue, RSyntaxKind, RSyntaxNode, }; -use biome_rowan::{AstNode, AstSeparatedList, SyntaxNodeCast, TextRange}; +use biome_rowan::{AstNode, AstSeparatedList, SyntaxNodeCast, TextRange, TextSize}; use oak_core::syntax_ext::{RIdentifierExt, RStringValueExt}; use oak_semantic::DefinitionId; use oak_semantic::semantic_index::{Definition, DefinitionKind, ScopeId, SemanticIndex}; @@ -34,11 +34,18 @@ pub struct SemanticInfo<'a> { /// Path of the file being analyzed. Used to resolve `source("path")` /// arguments against the current file's directory. file: &'a std::path::Path, - /// Names that have a synthetic use from AST passes (string interpolation, - /// `do.call("f", …)`, `..cols`, `on.exit` bodies, loop assignment LHSes, - /// short-circuit assignment LHSes). A definition whose symbol name is in - /// this set is treated as used. + /// Names that have a synthetic use from AST passes (`do.call("f", …)`, + /// `..cols`, `on.exit` bodies, loop assignment LHSes, short-circuit + /// assignment LHSes, custom infix operators). A definition whose symbol + /// name is in this set is treated as used. synthetic_used_names: HashSet, + /// String-interpolation reads (`glue("{x}")`, cli markup, custom + /// delimiters) as `(name, read range)` pairs. Unlike `synthetic_used_names`, + /// these are resolved position-aware in + /// [`Self::precompute_interpolation_uses`]: a read resolves to the + /// definition it actually sees, so a *later* same-scope reassignment of the + /// name is not kept alive by it. + interpolation_uses: Vec<(String, TextRange)>, /// Identifier `Use` ranges that should be ignored because they sit inside /// an NSE call argument (`quote(x)`, `substitute(…)`, …). nse_ranges: Vec, @@ -72,6 +79,7 @@ impl<'a> SemanticInfo<'a> { root: root.clone(), file, synthetic_used_names: HashSet::new(), + interpolation_uses: Vec::new(), nse_ranges: Vec::new(), unquote_ranges: Vec::new(), local_body_ranges: Vec::new(), @@ -81,6 +89,7 @@ impl<'a> SemanticInfo<'a> { this.collect_ast_passes(expressions); let scopes = this.scope_ids(); this.precompute_reaching_uses(&scopes); + this.precompute_interpolation_uses(); this } @@ -215,12 +224,15 @@ impl<'a> SemanticInfo<'a> { let Some(token) = node.first_token() else { return; }; + // The read happens where the string sits, so identifiers inside it + // resolve against the definitions live at this position. + let read_range = node.text_trimmed_range(); // cli's inline markup (`{.field {x}}`) interleaves styling with // interpolation, so strings inside a cli call need a markup-aware scan // rather than the plain glue scan. if node_in_cli_markup_call(node) { if let Some(content) = strings::get_string_literal_contents(token.text_trimmed()) { - self.collect_cli_interpolation(&content); + self.collect_cli_interpolation(&content, read_range); } return; } @@ -229,7 +241,7 @@ impl<'a> SemanticInfo<'a> { // the delimiters via `.open`/`.close` are handled separately in // `collect_custom_glue_interpolation`. for segment in scan_interpolation_segments(token.text_trimmed(), "{", "}") { - self.collect_identifiers_in_interpolation(segment); + self.collect_identifiers_in_interpolation(segment, read_range); } } @@ -241,12 +253,12 @@ impl<'a> SemanticInfo<'a> { /// still interpolated. So `{.field {x}}` uses `x`, but `{.field x}` does /// not. Markup spans recurse into their content; plain segments are parsed /// as R code. - fn collect_cli_interpolation(&mut self, content: &str) { + fn collect_cli_interpolation(&mut self, content: &str, read_range: TextRange) { for segment in scan_interpolation_segments(content, "{", "}") { if let Some(inner) = cli_markup_content(segment) { - self.collect_cli_interpolation(inner); + self.collect_cli_interpolation(inner, read_range); } else { - self.collect_identifiers_in_interpolation(segment); + self.collect_identifiers_in_interpolation(segment, read_range); } } } @@ -283,16 +295,18 @@ impl<'a> SemanticInfo<'a> { let Some(content) = strings::get_string_literal_contents(token.text_trimmed()) else { continue; }; + let read_range = value.text_trimmed_range(); for segment in scan_interpolation_segments(&content, &open, &close) { - self.collect_identifiers_in_interpolation(segment); + self.collect_identifiers_in_interpolation(segment, read_range); } } } /// Parse a glue-style `{...}` interpolation as R code and collect every - /// identifier reference. Skips the field side of `x$a` / `x@a` and the - /// namespace side of `pkg::name` — those name members, not bindings. - fn collect_identifiers_in_interpolation(&mut self, src: &str) { + /// identifier reference as an interpolation use at `read_range`. Skips the + /// field side of `x$a` / `x@a` and the namespace side of `pkg::name` — + /// those name members, not bindings. + fn collect_identifiers_in_interpolation(&mut self, src: &str, read_range: TextRange) { let parsed = air_r_parser::parse(src, RParserOptions::default()); if parsed.has_error() { return; @@ -305,8 +319,8 @@ impl<'a> SemanticInfo<'a> { continue; } if let Some(token) = node.first_token() { - self.synthetic_used_names - .insert(token.text_trimmed().to_string()); + self.interpolation_uses + .push((token.text_trimmed().to_string(), read_range)); } } } @@ -565,6 +579,61 @@ impl<'a> SemanticInfo<'a> { best.map(|(id, _)| id) } + /// Resolve each string-interpolation read to the definition(s) it uses and + /// record them in `reaching_used`, mirroring the real-use pass. Runs after + /// the AST pass so the NSE ranges it consults are already collected. + fn precompute_interpolation_uses(&mut self) { + let uses = std::mem::take(&mut self.interpolation_uses); + for (name, read_range) in &uses { + self.mark_interpolation_use(name, read_range.start()); + } + } + + /// Mark the definition an interpolated read of `name` at `pos` resolves to. + /// + /// Interpolation reads are position-aware, so — unlike `synthetic_used_names` + /// — a *later* same-scope reassignment of the name isn't kept alive. Walk + /// outward from the reading scope to the scope that binds `name`: + /// - if that's the reading scope, only the nearest preceding definition is + /// read (sequential execution); + /// - if it's an enclosing scope, the read is a closure capture evaluated + /// later, so textual order is irrelevant and every definition of the name + /// there is kept alive. + fn mark_interpolation_use(&mut self, name: &str, pos: TextSize) { + let index = self.index; + let (read_scope, _) = index.scope_at(pos); + for owner in index.ancestor_scope_ids(read_scope) { + let Some(symbol_id) = index.symbols(owner).id(name) else { + continue; + }; + let defs: Vec<(DefinitionId, TextSize)> = index + .definitions(owner) + .iter() + .filter(|(_, def)| def.symbol() == symbol_id && !self.is_in_nse(def.range())) + .map(|(id, def)| (id, def.range().start())) + .collect(); + // `name` may be referenced but not bound in this scope; if so, keep + // walking outward to the scope that actually binds it. + if defs.is_empty() { + continue; + } + if owner == read_scope { + if let Some(&(def_id, _)) = defs + .iter() + .filter(|(_, start)| *start < pos) + .max_by_key(|(_, start)| *start) + { + self.reaching_used.insert((owner, def_id)); + } + } else { + for (def_id, _) in defs { + self.reaching_used.insert((owner, def_id)); + } + } + return; + } + } + fn is_used_inside_local_body(&self, scope_id: ScopeId, def: &Definition) -> bool { if self.local_body_ranges.is_empty() { return false; @@ -610,8 +679,10 @@ fn in_any_range(target: TextRange, ranges: &[TextRange]) -> bool { /// Extract glue-style interpolation segments delimited by `open`/`close`. /// Doubled delimiters (`{{`/`}}` for the default case) are glue escapes and /// are skipped. Nested delimiters are tracked so `{f({x})}` yields the whole -/// inner expression. Returns the source slices between the outermost -/// delimiter pairs. +/// inner expression — except when `open == close` (e.g. `.open`/`.close` +/// both `|`), where the delimiters are indistinguishable so nesting is +/// impossible and the first delimiter after an opener always closes it. +/// Returns the source slices between the outermost delimiter pairs. fn scan_interpolation_segments<'t>(text: &'t str, open: &str, close: &str) -> Vec<&'t str> { let mut segments = Vec::new(); if open.is_empty() || close.is_empty() { @@ -637,7 +708,9 @@ fn scan_interpolation_segments<'t>(text: &'t str, open: &str, close: &str) -> Ve let mut end = start; while end < text.len() && depth > 0 { let rest = &text[end..]; - if rest.starts_with(open) { + // When `open == close` a delimiter can only close the current + // segment; treating it as a nested opener would never balance. + if open != close && rest.starts_with(open) { depth += 1; end += open.len(); } else if rest.starts_with(close) { From b605dba7a0853c4068df1cca14da2846fd66b608 Mon Sep 17 00:00:00 2001 From: etiennebacher Date: Wed, 1 Jul 2026 21:26:36 +0100 Subject: [PATCH 37/39] fix glue with if else --- .../src/lints/base/unused_object/mod.rs | 11 ++++++++++ crates/jarl-semantic/src/lib.rs | 22 ++++++++----------- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/crates/jarl-core/src/lints/base/unused_object/mod.rs b/crates/jarl-core/src/lints/base/unused_object/mod.rs index 2e249f224..160f684be 100644 --- a/crates/jarl-core/src/lints/base/unused_object/mod.rs +++ b/crates/jarl-core/src/lints/base/unused_object/mod.rs @@ -296,6 +296,17 @@ foo <- \"b\""), ); } + #[test] + fn test_no_lint_interpolation_reads_branch_assignments() { + // Both `if`/`else` arms assign `x` in the same scope and both reach the + // later `glue` read through branching control flow, so neither is unused. + expect_no_lint( + "if (a) {\n x <- \"a\"\n} else {\n x <- \"b\"\n}\nglue(\"{x}\")", + "unused_object", + None, + ); + } + #[test] fn test_no_lint_interpolation_captures_enclosing_definition() { // The interpolated read is a closure capture evaluated later, so the diff --git a/crates/jarl-semantic/src/lib.rs b/crates/jarl-semantic/src/lib.rs index 719b4cf72..cfff23205 100644 --- a/crates/jarl-semantic/src/lib.rs +++ b/crates/jarl-semantic/src/lib.rs @@ -589,13 +589,16 @@ impl<'a> SemanticInfo<'a> { } } - /// Mark the definition an interpolated read of `name` at `pos` resolves to. + /// Mark the definition(s) an interpolated read of `name` at `pos` resolves + /// to. /// /// Interpolation reads are position-aware, so — unlike `synthetic_used_names` /// — a *later* same-scope reassignment of the name isn't kept alive. Walk /// outward from the reading scope to the scope that binds `name`: - /// - if that's the reading scope, only the nearest preceding definition is - /// read (sequential execution); + /// - if that's the reading scope, mark every definition that precedes the + /// read: more than one can reach it through branching control flow (e.g. + /// an `if`/`else` assigning in both arms), but a later reassignment is + /// excluded so it stays reported; /// - if it's an enclosing scope, the read is a closure capture evaluated /// later, so textual order is irrelevant and every definition of the name /// there is kept alive. @@ -617,16 +620,9 @@ impl<'a> SemanticInfo<'a> { if defs.is_empty() { continue; } - if owner == read_scope { - if let Some(&(def_id, _)) = defs - .iter() - .filter(|(_, start)| *start < pos) - .max_by_key(|(_, start)| *start) - { - self.reaching_used.insert((owner, def_id)); - } - } else { - for (def_id, _) in defs { + let captured = owner != read_scope; + for (def_id, start) in defs { + if captured || start < pos { self.reaching_used.insert((owner, def_id)); } } From 2a9671f03654fef235e4ee32edce3991698fe38e Mon Sep 17 00:00:00 2001 From: etiennebacher Date: Fri, 3 Jul 2026 13:21:20 +0100 Subject: [PATCH 38/39] handle loose scripts --- crates/jarl-core/src/db.rs | 145 +++++++++-- .../lints/base/unused_object/unused_object.rs | 13 +- crates/jarl-core/src/package.rs | 36 ++- crates/jarl-semantic/src/lib.rs | 102 ++++++-- .../jarl/tests/integration/unused_object.rs | 245 ++++++++++++++++++ 5 files changed, 486 insertions(+), 55 deletions(-) diff --git a/crates/jarl-core/src/db.rs b/crates/jarl-core/src/db.rs index 5fdd5b7e9..ef98f5811 100644 --- a/crates/jarl-core/src/db.rs +++ b/crates/jarl-core/src/db.rs @@ -5,7 +5,10 @@ //! the two by scanning only the **package roots** that the linted paths //! belong to (bounded by `DESCRIPTION` discovery), never the unbounded //! parent directory of a loose script. That keeps a `jarl /tmp/foo.R` -//! invocation from walking all of `/tmp`. +//! invocation from walking all of `/tmp`. Loose scripts still take part in +//! cross-file analysis: the lint set itself is their file universe, so they +//! are handed to [`AnalysisDb::cross_file_used_objects`] directly and +//! resolve against each other through explicit `source()` edges. //! //! The database is built and queried in jarl's *sequential* pre-pass //! ([`crate::package::make_package_analysis`]), not the parallel per-file @@ -22,7 +25,7 @@ use aether_path::FilePath; use air_r_parser::RParserOptions; use oak_db::{Db, File, OakDatabase, Package, workspace_files}; use oak_scan::ScanScheduler; -use oak_semantic::semantic_index::SemanticIndex; +use oak_semantic::semantic_index::{DefinitionKind, SemanticIndex}; use rayon::prelude::*; use crate::package::find_package_root; @@ -46,19 +49,25 @@ pub struct AnalysisDb { } /// One file's contribution to cross-file resolution: the names it binds at -/// top level, and the names it reads *freely* — without binding them anywhere -/// in the file, so they reference the package namespace. +/// top level, the names it reads *freely* — without binding them anywhere +/// in the file, so they reference the package namespace — and the +/// `source()` bindings it consumes (a read reaching a +/// [`DefinitionKind::Import`] uses the target file's top-level binding). struct FileUses { path: PathBuf, top_defs: Vec, free_uses: HashSet, + /// `(target file, name)` per `Import`-kind definition reached by a use: + /// this file reads `name` out of `target file` via `source()`. + import_uses: HashSet<(PathBuf, String)>, } /// Result of the package-wide cross-file pass. #[derive(Default)] pub struct CrossFileAnalysis { /// Per defining-file (relativized path): top-level object names read from - /// another file in the same package. + /// another file, either through the shared package namespace or through a + /// `source()` edge. pub used: HashMap>, /// Per-file (relativized path) semantic index, built once here and shared /// with the parallel lint pass so it isn't rebuilt. @@ -136,8 +145,9 @@ impl AnalysisDb { packages } - /// For each scanned file, the set of its top-level object names that are - /// read from *another* file in the same package. + /// For each analyzed file, the set of its top-level object names that are + /// read from *another* file — through the package namespace or through a + /// `source()` edge. /// /// A package's R files share one namespace, so a top-level binding defined /// in one file and read in another is used even when its own file never @@ -148,6 +158,13 @@ impl AnalysisDb { /// oak's `resolve_at` uses to decide local-vs-cross-file). A top-level /// definition is cross-file-used when another file reads its name freely. /// + /// `script_paths` are the linted R files living outside any package. They + /// don't share a namespace with anything, so they skip the free-use + /// matching above and participate only through the `source()` edges below: + /// a read reaching a `DefinitionKind::Import` marks the *target* file's + /// binding used, chasing forwards when the target itself sources the real + /// definer. + /// /// This avoids per-use `File::resolve_at`, which has to run on a single /// thread because the salsa db is `!Sync`. The index work here is db-free /// and runs on the rayon pool; only the cheap final merge is sequential. @@ -159,23 +176,33 @@ impl AnalysisDb { /// so they're identical to what the lint pass would build. /// /// Keyed by relativized file path to match the lint's per-file lookup. - pub fn cross_file_used_objects(&self) -> CrossFileAnalysis { + pub fn cross_file_used_objects(&self, script_paths: &[PathBuf]) -> CrossFileAnalysis { let db = self.db(); // Collect just the paths up front. `path()` is a salsa query that needs // the (`!Sync`) db, but it touches no disk, so this sequential loop is // cheap. Reading the file contents — the part that scales with file - // count — is deferred to the parallel pass below. - let paths: Vec = workspace_files(db) + // count — is deferred to the parallel pass below. Package files take + // part in namespace-based matching; loose scripts (outside every + // scanned root) are appended as source-edge-only participants. + let mut paths: Vec<(PathBuf, bool)> = workspace_files(db) .iter() .filter_map(|&file| { let path = file .path(db) .as_path() .map(|p| p.as_std_path().to_path_buf())?; - Some(PathBuf::from(crate::fs::relativize_path(&path))) + Some((PathBuf::from(crate::fs::relativize_path(&path)), true)) }) .collect(); + let scanned: HashSet<&PathBuf> = paths.iter().map(|(path, _)| path).collect(); + let scripts: Vec<(PathBuf, bool)> = script_paths + .iter() + .map(|path| PathBuf::from(crate::fs::relativize_path(path))) + .filter(|path| !scanned.contains(path)) + .map(|path| (path, false)) + .collect(); + paths.extend(scripts); // Per file, in parallel: read the source, parse, build the index, and // read off the file's top-level definitions and its free uses. None of @@ -186,7 +213,7 @@ impl AnalysisDb { // CLI the disk is the source of truth, so the two are equivalent. let built: Vec<(Arc, FileUses)> = paths .par_iter() - .filter_map(|path| { + .filter_map(|(path, in_package)| { let source = std::fs::read_to_string(path).ok()?; let parsed = air_r_parser::parse(&source, RParserOptions::default()); if parsed.has_error() { @@ -196,7 +223,7 @@ impl AnalysisDb { &parsed.tree(), jarl_semantic::JarlImportsResolver::new(path.clone()), ); - let uses = collect_file_uses(path.clone(), &index); + let uses = collect_file_uses(path.clone(), &index, *in_package); Some((Arc::new(index), uses)) }) .collect(); @@ -234,6 +261,42 @@ impl AnalysisDb { } } + // `source()` edges: a read that reaches an `Import`-kind definition + // consumes the target file's top-level binding, so mark it used there. + // A target may forward the name from a file it sources itself; chase + // those `Import`-kind exports to the real definer (jarl's parallel of + // oak_db's `File::collect_exports`), marking every hop. The visited + // set makes `source()` cycles terminate. `exports()` is recomputed + // per hop, but chains are short and edges few, so a per-file cache + // isn't worth it. + let index_by_path: HashMap<&Path, &SemanticIndex> = built + .iter() + .map(|(index, file)| (file.path.as_path(), index.as_ref())) + .collect(); + let mut pending: Vec<(PathBuf, String)> = built + .iter() + .flat_map(|(_, file)| file.import_uses.iter().cloned()) + .collect(); + let mut chased: HashSet<(PathBuf, String)> = HashSet::new(); + while let Some((path, name)) = pending.pop() { + if !chased.insert((path.clone(), name.clone())) { + continue; + } + used.entry(path.clone()).or_default().insert(name.clone()); + let Some(index) = index_by_path.get(path.as_path()) else { + continue; + }; + for &(_, def) in index.exports().get(name.as_str()).into_iter().flatten() { + let DefinitionKind::Import { file: url, name: forwarded, .. } = def.kind() else { + continue; + }; + let Some(target) = import_target_path(url) else { + continue; + }; + pending.push((target, forwarded.clone())); + } + } + let indices = built .iter() .map(|(index, file)| (file.path.clone(), Arc::clone(index))) @@ -242,32 +305,66 @@ impl AnalysisDb { } } -/// Collect a file's top-level definitions and its free uses from its index. +/// Collect a file's top-level definitions, its free uses, and its `source()` +/// import uses from its index. /// /// A use is *free* when no definition reaches it within the file — the same /// `reaching_definitions().is_empty()` test oak's `resolve_at` uses before /// falling back to cross-file resolution. Reaching definitions already fold in /// enclosing-scope captures, so a closure reading an outer local counts as /// bound, not free. -fn collect_file_uses(path: PathBuf, index: &SemanticIndex) -> FileUses { - let top_defs: Vec = index - .exports() - .keys() - .map(|name| name.to_string()) - .collect(); +/// +/// A use reaching a [`DefinitionKind::Import`] instead reads a binding that +/// `source()` injected from another file, so it's recorded as an import use +/// of `(target file, name)`. +/// +/// `in_package` gates the namespace-based side (`top_defs` / `free_uses`): +/// loose scripts share no namespace, so name matching would count unrelated +/// scripts as readers of each other. They contribute import uses only. +fn collect_file_uses(path: PathBuf, index: &SemanticIndex, in_package: bool) -> FileUses { + let top_defs: Vec = if in_package { + index + .exports() + .keys() + .map(|name| name.to_string()) + .collect() + } else { + Vec::new() + }; let mut free_uses: HashSet = HashSet::new(); + let mut import_uses: HashSet<(PathBuf, String)> = HashSet::new(); for scope in index.scope_ids() { let symbols = index.symbols(scope); for (use_id, use_site) in index.uses(scope).iter() { - if index.reaching_definitions(scope, use_id).next().is_some() { - continue; + let mut reached = false; + for (def_scope, def_id) in index.reaching_definitions(scope, use_id) { + reached = true; + let def = &index.definitions(def_scope)[def_id]; + let DefinitionKind::Import { file: url, name, .. } = def.kind() else { + continue; + }; + let Some(target) = import_target_path(url) else { + continue; + }; + import_uses.insert((target, name.clone())); + } + if !reached && in_package { + free_uses.insert(symbols.symbol(use_site.symbol()).name().to_string()); } - free_uses.insert(symbols.symbol(use_site.symbol()).name().to_string()); } } - FileUses { path, top_defs, free_uses } + FileUses { path, top_defs, free_uses, import_uses } +} + +/// Convert an `Import` definition's file URL back to the relativized path +/// that keys [`CrossFileAnalysis`]'s maps. [`jarl_semantic::JarlImportsResolver`] +/// builds these URLs from absolutized paths, so the round-trip through +/// `to_file_path` + relativize lands on the same key as the linted file's. +fn import_target_path(url: &url::Url) -> Option { + let path = url.to_file_path().ok()?; + Some(PathBuf::from(crate::fs::relativize_path(path))) } /// Resolve a list of database [`File`]s to their filesystem paths, dropping diff --git a/crates/jarl-core/src/lints/base/unused_object/unused_object.rs b/crates/jarl-core/src/lints/base/unused_object/unused_object.rs index d344e7b4d..425f5ef72 100644 --- a/crates/jarl-core/src/lints/base/unused_object/unused_object.rs +++ b/crates/jarl-core/src/lints/base/unused_object/unused_object.rs @@ -51,8 +51,10 @@ pub fn unused_object( if info.is_definition_used(scope_id, def_id, def) { continue; } - // Top-level bindings are shared across a package's files, so an - // object read from a sibling file (or exported) is still used. + // Top-level bindings are visible to other files — package + // siblings share the namespace, and `source()` injects them into + // the sourcing file — so an object read from another file (or + // exported) is still used. if scope_id == top_level && (is_exported(semantic, exports, scope_id, def) || is_used_cross_file(semantic, cross_file_used, scope_id, def)) @@ -124,9 +126,10 @@ fn is_exported( exports.contains(name) } -/// True when this top-level binding is read from another file in the same -/// package. `cross_file_used` is precomputed from oak's cross-file resolution -/// (see [`crate::db::AnalysisDb::cross_file_used_objects`]). +/// True when this top-level binding is read from another file — a sibling in +/// the same package, or a file that `source()`s this one. `cross_file_used` +/// is precomputed from oak's cross-file resolution (see +/// [`crate::db::AnalysisDb::cross_file_used_objects`]). fn is_used_cross_file( semantic: &SemanticIndex, cross_file_used: &std::collections::HashSet, diff --git a/crates/jarl-core/src/package.rs b/crates/jarl-core/src/package.rs index 1d8056548..d1644b2c2 100644 --- a/crates/jarl-core/src/package.rs +++ b/crates/jarl-core/src/package.rs @@ -83,11 +83,12 @@ pub struct PackageAnalysis { /// exported. pub unused_functions: HashMap>, /// Per-file set of top-level object names that are read from *another* - /// file in the same package. Keyed by relativized file path. All of a - /// package's R files share one namespace, so a top-level binding defined - /// in one file and used in another is not unused; `unused_object` - /// consults this to avoid flagging such cross-file-used objects. Computed - /// from oak's cross-file name resolution (`File::resolve_at`). + /// file. Keyed by relativized file path. All of a package's R files share + /// one namespace, so a top-level binding defined in one file and used in + /// another is not unused; the same holds for a binding in a script that + /// another file `source()`s and then reads. `unused_object` consults this + /// to avoid flagging such cross-file-used objects. Computed in + /// [`crate::db::AnalysisDb::cross_file_used_objects`]. pub cross_file_used: HashMap>, /// Per-file semantic index built during the cross-file pass, keyed by /// relativized path. The parallel lint pass reuses these instead of @@ -364,8 +365,11 @@ pub fn make_package_analysis( // Reuse the database scanned above: find top-level objects read from // another file, and keep the per-file indices for the lint pass to reuse. + // Loose scripts have no package root for the db to scan, so the lint set + // itself is their file universe: hand them over directly and they resolve + // against each other through `source()` edges. let cross_file = if check_unused_object { - db.cross_file_used_objects() + db.cross_file_used_objects(&loose_script_paths(paths)) } else { crate::db::CrossFileAnalysis::default() }; @@ -378,6 +382,26 @@ pub fn make_package_analysis( } } +/// The linted R files that live outside any R package (no `DESCRIPTION` +/// ancestor). These are invisible to the oak scan — which is bounded by +/// package roots — so the cross-file pass takes them straight from the lint +/// set. Package membership is cached per parent directory: siblings share +/// the walk up to the root. +fn loose_script_paths(paths: &[PathBuf]) -> Vec { + let mut in_package_by_dir: HashMap = HashMap::new(); + paths + .iter() + .filter(|path| has_r_extension(path)) + .filter(|path| { + let dir = path.parent().unwrap_or_else(|| Path::new("")).to_path_buf(); + !*in_package_by_dir + .entry(dir) + .or_insert_with(|| find_package_root(path).is_some()) + }) + .cloned() + .collect() +} + /// Determine the `FileScope` for a non-R/ file based on its path. pub(crate) fn file_scope_from_path(path: &Path) -> FileScope { let components: Vec<_> = path diff --git a/crates/jarl-semantic/src/lib.rs b/crates/jarl-semantic/src/lib.rs index cfff23205..18a9bd254 100644 --- a/crates/jarl-semantic/src/lib.rs +++ b/crates/jarl-semantic/src/lib.rs @@ -963,28 +963,62 @@ fn string_literal_value(node: &RSyntaxNode) -> Option { } /// Resolve a `source("path")` argument against the currently-analyzed file. -/// Absolute paths are taken as-is; relative paths are joined to the -/// directory containing the analyzed file. When the analyzed file has no -/// parent directory (e.g. just a bare filename), the relative path is -/// returned as-is — `std::fs` will resolve it against the process CWD. +/// +/// Absolute paths are taken as-is. Relative paths are tried against a chain +/// of anchors, nearest first: the analyzed file's directory (helpers usually +/// sit next to the script), then each ancestor up to the process CWD. R +/// itself resolves `source()` against `getwd()`, and the project root a +/// script is run from sits somewhere between the file and where jarl was +/// invoked — trying every level catches layouts like `jarl check foo` where +/// `foo/sub/a.R` sources a file at `foo/` (the same reason oak's salsa +/// resolver anchors at the workspace root). Lint paths are CWD-relative, so +/// walking their ancestors down to `""` is exactly that chain and never +/// escapes the CWD. fn resolve_sourced_path(current_file: &std::path::Path, path: &str) -> Option { let candidate = std::path::Path::new(path); if candidate.is_absolute() { return Some(candidate.to_path_buf()); } - match current_file.parent() { - Some(dir) if !dir.as_os_str().is_empty() => Some(dir.join(candidate)), - _ => Some(candidate.to_path_buf()), + let file_dir = current_file.parent().unwrap_or(std::path::Path::new("")); + let fallback = file_dir.join(candidate); + + if current_file.is_absolute() { + // An absolute analyzed path has no CWD-bounded ancestor chain to + // walk (it would climb toward the filesystem root); anchor next to + // the file, then at the CWD. + if fallback.is_file() { + return Some(fallback); + } + if candidate.is_file() { + return Some(candidate.to_path_buf()); + } + return Some(fallback); + } + + let mut dir = file_dir; + loop { + let resolved = dir.join(candidate); + if resolved.is_file() { + return Some(resolved); + } + if dir.as_os_str().is_empty() { + // Every anchor between the file and the CWD missed; keep the + // file-relative guess so callers fail uniformly when reading it. + return Some(fallback); + } + dir = dir.parent().unwrap_or(std::path::Path::new("")); } } /// `ImportsResolver` impl that plugs `source("path")` injection into oak's /// builder. /// -/// The resolver parses the target file, builds a noop-resolved -/// `SemanticIndex` for it, and reports its top-level definitions as -/// `SourceResolution.names`. Oak then materialises `DefinitionKind::Import` -/// entries at the `source()` call site in the calling file's index. +/// The resolver parses the target file, builds its `SemanticIndex` with +/// another `JarlImportsResolver` (so `source()` chains resolve +/// transitively), and reports its top-level definitions — own and +/// forwarded — as `SourceResolution.names`. Oak then materialises +/// `DefinitionKind::Import` entries at the `source()` call site in the +/// calling file's index. /// /// This handles the *defined-by-source* side of `source()` semantics. /// The complementary *used-by-source* side — names *read* by the sourced @@ -993,33 +1027,61 @@ fn resolve_sourced_path(current_file: &std::path::Path, path: &str) -> Option>>, } impl JarlImportsResolver { pub fn new(current_file: impl Into) -> Self { - Self { current_file: current_file.into() } + let current_file = current_file.into(); + let mut visited = HashSet::new(); + visited.insert(absolutize_path(¤t_file)); + Self { + current_file, + visited: std::rc::Rc::new(std::cell::RefCell::new(visited)), + } } } impl oak_semantic::ImportsResolver for JarlImportsResolver { fn resolve_source(&mut self, path: &str) -> Option { let target = resolve_sourced_path(&self.current_file, path)?; + let target_key = absolutize_path(&target); + if !self.visited.borrow_mut().insert(target_key.clone()) { + return None; + } let contents = std::fs::read_to_string(&target).ok()?; let parsed = air_r_parser::parse(&contents, RParserOptions::default()); if parsed.has_error() { return None; } + // The URL is built from the absolutized path so consumers (e.g. the + // cross-file pre-pass) can round-trip it back to a filesystem path. // `Url::from_file_path` rejects relative paths; fall back to a - // synthetic `file:///` URL so test fixtures (which often use - // relative paths) still index. - let url = url::Url::from_file_path(&target) + // synthetic `file:///` URL so exotic paths still index. + let url = url::Url::from_file_path(&target_key) .ok() - .or_else(|| url::Url::parse(&format!("file:///{}", target.display())).ok())?; - let sub_index = - oak_semantic::build_index(&parsed.tree(), oak_semantic::NoopImportsResolver); - // TODO: extend to transitive `source()` resolution by recursing - // through nested `SemanticCallKind::Source` entries here. + .or_else(|| url::Url::parse(&format!("file:///{}", target_key.display())).ok())?; + // Recurse with the chain's visited set: the target's own `source()` + // calls inject Import entries into its index, so its exports below + // include names it forwards from deeper files. + let sub_resolver = JarlImportsResolver { + current_file: target, + visited: std::rc::Rc::clone(&self.visited), + }; + let sub_index = oak_semantic::build_index(&parsed.tree(), sub_resolver); let names: Vec = sub_index.exports().keys().map(|s| s.to_string()).collect(); Some(oak_semantic::SourceResolution { url, names, packages: Vec::new() }) } } + +/// Absolutize `path` against the process CWD, without touching the +/// filesystem. Gives `source()` targets a canonical key so cycle detection +/// and URL construction agree regardless of how the path was spelled. +fn absolutize_path(path: &std::path::Path) -> std::path::PathBuf { + std::path::absolute(path).unwrap_or_else(|_| path.to_path_buf()) +} diff --git a/crates/jarl/tests/integration/unused_object.rs b/crates/jarl/tests/integration/unused_object.rs index ee44c6947..5fb03cf57 100644 --- a/crates/jarl/tests/integration/unused_object.rs +++ b/crates/jarl/tests/integration/unused_object.rs @@ -129,6 +129,251 @@ fn test_object_unused_across_package_files_is_flagged() -> anyhow::Result<()> { Ok(()) } +#[test] +fn test_object_read_by_sourcing_script_not_flagged() -> anyhow::Result<()> { + // Loose scripts (no DESCRIPTION anywhere): `a.R` sources `b.R` and then + // reads `x`, so `b.R`'s binding is consumed cross-file and not unused. + let case = CliTest::with_files([("b.R", "x <- 1\n"), ("a.R", "source(\"b.R\")\nprint(x)\n")])?; + + insta::assert_snapshot!( + &mut case + .command() + .arg("check") + .arg(".") + .arg("--select") + .arg("unused_object") + .run() + .normalize_os_executable_name(), + @" + + success: true + exit_code: 0 + ----- stdout ----- + ── Summary ────────────────────────────────────── + All checks passed! + + ----- stderr ----- + " + ); + + Ok(()) +} + +#[test] +fn test_source_path_anchored_at_cwd_not_flagged() -> anyhow::Result<()> { + // R resolves `source("b.R")` against `getwd()`, so a script in a subfolder + // run from the project root reads the root's `b.R`. Nothing exists next to + // `foo/a.R`, so resolution falls back to the CWD anchor. + let case = CliTest::with_files([ + ("b.R", "x <- 1\n"), + ("foo/a.R", "source(\"b.R\")\nprint(x)\n"), + ])?; + + insta::assert_snapshot!( + &mut case + .command() + .arg("check") + .arg(".") + .arg("--select") + .arg("unused_object") + .run() + .normalize_os_executable_name(), + @" + + success: true + exit_code: 0 + ----- stdout ----- + ── Summary ────────────────────────────────────── + All checks passed! + + ----- stderr ----- + " + ); + + Ok(()) +} + +#[test] +fn test_source_path_anchored_at_linted_folder_not_flagged() -> anyhow::Result<()> { + // Scripts of a project run from its root anchor `source()` there, even + // when the project sits below jarl's CWD: `foo/sub/a.R` sources `b.R` + // living at `foo/`, and `jarl check foo` runs from the parent. The + // ancestor walk between the file's directory and the CWD finds it. + let case = CliTest::with_files([ + ("foo/b.R", "x <- 1\n"), + ("foo/sub/a.R", "source(\"b.R\")\nprint(x)\n"), + ])?; + + insta::assert_snapshot!( + &mut case + .command() + .arg("check") + .arg("foo") + .arg("--select") + .arg("unused_object") + .run() + .normalize_os_executable_name(), + @" + + success: true + exit_code: 0 + ----- stdout ----- + ── Summary ────────────────────────────────────── + All checks passed! + + ----- stderr ----- + " + ); + + Ok(()) +} + +#[test] +fn test_object_not_read_by_sourcing_script_is_flagged() -> anyhow::Result<()> { + // Sourcing alone doesn't consume a binding: `a.R` runs `b.R` but never + // reads `x`, so `x` is still unused. + let case = CliTest::with_files([ + ("b.R", "x <- 1\n"), + ("a.R", "source(\"b.R\")\nprint(\"hi\")\n"), + ])?; + + insta::assert_snapshot!( + &mut case + .command() + .arg("check") + .arg(".") + .arg("--select") + .arg("unused_object") + .run() + .normalize_os_executable_name(), + @r" + + success: false + exit_code: 1 + ----- stdout ----- + warning: unused_object + --> b.R:1:1 + | + 1 | x <- 1 + | - Object `x` is defined but never used. + | + + + ── Summary ────────────────────────────────────── + Found 1 error. + + ----- stderr ----- + " + ); + + Ok(()) +} + +#[test] +fn test_object_read_through_source_chain_not_flagged() -> anyhow::Result<()> { + // Transitive chain: `a.R` sources `b.R`, which sources `c.R`. The read of + // `z` in `a.R` reaches `c.R`'s binding through the forwarded exports. + let case = CliTest::with_files([ + ("c.R", "z <- 1\n"), + ("b.R", "source(\"c.R\")\n"), + ("a.R", "source(\"b.R\")\nprint(z)\n"), + ])?; + + insta::assert_snapshot!( + &mut case + .command() + .arg("check") + .arg(".") + .arg("--select") + .arg("unused_object") + .run() + .normalize_os_executable_name(), + @" + + success: true + exit_code: 0 + ----- stdout ----- + ── Summary ────────────────────────────────────── + All checks passed! + + ----- stderr ----- + " + ); + + Ok(()) +} + +#[test] +fn test_source_cycle_terminates() -> anyhow::Result<()> { + // `p.R` and `q.R` source each other. Resolution must terminate, and the + // read of `k` in `p.R` still consumes `q.R`'s binding. + let case = CliTest::with_files([ + ("p.R", "source(\"q.R\")\nprint(k)\n"), + ("q.R", "source(\"p.R\")\nk <- 1\n"), + ])?; + + insta::assert_snapshot!( + &mut case + .command() + .arg("check") + .arg(".") + .arg("--select") + .arg("unused_object") + .run() + .normalize_os_executable_name(), + @" + + success: true + exit_code: 0 + ----- stdout ----- + ── Summary ────────────────────────────────────── + All checks passed! + + ----- stderr ----- + " + ); + + Ok(()) +} + +#[test] +fn test_free_name_in_unrelated_script_does_not_suppress() -> anyhow::Result<()> { + // Unlike package files, loose scripts share no namespace: `e.R` reads a + // free `y` but never sources `d.R`, so `d.R`'s `y` stays unused. + let case = CliTest::with_files([("d.R", "y <- 1\n"), ("e.R", "print(y)\n")])?; + + insta::assert_snapshot!( + &mut case + .command() + .arg("check") + .arg(".") + .arg("--select") + .arg("unused_object") + .run() + .normalize_os_executable_name(), + @r" + + success: false + exit_code: 1 + ----- stdout ----- + warning: unused_object + --> d.R:1:1 + | + 1 | y <- 1 + | - Object `y` is defined but never used. + | + + + ── Summary ────────────────────────────────────── + Found 1 error. + + ----- stderr ----- + " + ); + + Ok(()) +} + #[test] fn test_unexported_alias_is_flagged() -> anyhow::Result<()> { // Same code, but no NAMESPACE export — `summarize_each` is dead. From 2bc503f86052220f05dd6215a29c1e52e2c9f4f3 Mon Sep 17 00:00:00 2001 From: etiennebacher Date: Tue, 7 Jul 2026 21:03:33 +0200 Subject: [PATCH 39/39] handle chained assignment of functions, see #540 --- .../src/lints/base/unused_object/mod.rs | 26 +++++++++++++++++++ crates/jarl-semantic/src/lib.rs | 20 +++++++++++--- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/crates/jarl-core/src/lints/base/unused_object/mod.rs b/crates/jarl-core/src/lints/base/unused_object/mod.rs index 160f684be..1f615bb1a 100644 --- a/crates/jarl-core/src/lints/base/unused_object/mod.rs +++ b/crates/jarl-core/src/lints/base/unused_object/mod.rs @@ -96,6 +96,32 @@ mod tests { expect_no_lint("f <- function(x) 1", "unused_object", None); } + #[test] + fn test_no_lint_chained_function_definition() { + // Every name in a chained function assignment is a function binding, so + // the function-definition exemption covers the whole chain. + expect_no_lint("x <- y <- function() {}", "unused_object", None); + } + + #[test] + fn test_lint_chained_non_function_assignment() { + assert_snapshot!(snapshot_lint("x <- y <- 1"), @r" + warning: unused_object + --> :1:6 + | + 1 | x <- y <- 1 + | - Object `y` is defined but never used. + | + warning: unused_object + --> :1:1 + | + 1 | x <- y <- 1 + | - Object `x` is defined but never used. + | + Found 2 errors. + "); + } + #[test] fn test_no_lint_used_in_closure() { expect_no_lint( diff --git a/crates/jarl-semantic/src/lib.rs b/crates/jarl-semantic/src/lib.rs index 9b54b80a3..85f99fdea 100644 --- a/crates/jarl-semantic/src/lib.rs +++ b/crates/jarl-semantic/src/lib.rs @@ -824,11 +824,25 @@ fn is_member_name(node: &RSyntaxNode) -> bool { } } -/// True if the RHS of a binary assignment is a function definition. +/// True if the value assigned by this binary assignment is a function +/// definition, following chained assignments (`x <- y <- function() {}`) down +/// to the innermost value so every name in the chain is treated as a function +/// binding. pub fn assignment_rhs_is_function_def(bin: &RBinaryExpression) -> bool { for child in bin.syntax().children() { - if child.kind() == RSyntaxKind::R_FUNCTION_DEFINITION { - return true; + match child.kind() { + RSyntaxKind::R_FUNCTION_DEFINITION => return true, + // The value is itself an assignment (`x <- y <- function() {}`), so + // follow the chain down to its own value. + RSyntaxKind::R_BINARY_EXPRESSION => { + if let Some(inner) = child.cast::() + && assignment_lhs_name(inner.syntax()).is_some() + && assignment_rhs_is_function_def(&inner) + { + return true; + } + } + _ => {} } } false