diff --git a/Cargo.lock b/Cargo.lock index 25b0d5718..2059b790c 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#d78217c897f1e58ba157c17f672432e8d431b888" +dependencies = [ + "anyhow", + "camino", + "log", + "percent-encoding", + "stdext", + "url", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -44,7 +57,7 @@ dependencies = [ "biome_formatter", "biome_rowan", "comments", - "itertools", + "itertools 0.13.0", "line_ending", "settings", "tracing", @@ -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" @@ -101,9 +120,9 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" @@ -120,7 +139,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]] @@ -131,14 +150,14 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys", ] [[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 = "ar_archive_writer" @@ -149,6 +168,12 @@ dependencies = [ "object", ] +[[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" @@ -301,6 +326,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" @@ -376,28 +410,34 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.0" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +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.23" +version = "0.9.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22a59e5c2bac9ccb280150ef4b58e07e4169d8c5c6ce0f70170e3c0b7c7b6124" +checksum = "0b86829876e7e200161a5aa6ea688d46c32d64d70ee3100127790dde84688d6e" dependencies = [ "bpaf_derive", ] [[package]] name = "bpaf_derive" -version = "0.5.23" +version = "0.5.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "549ca9c364fdc06f9f36d1356980193d930abc80db848193c2dd4d0e9a83de1b" +checksum = "2f7e98cee839b19076cb3ce1afdb62bb182e04ff5f71f70188827002fae91094" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -413,15 +453,24 @@ dependencies = [ [[package]] name = "camino" -version = "1.2.2" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +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.65" +version = "1.2.64" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" dependencies = [ "find-msvc-tools", "shlex", @@ -435,9 +484,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "clap" -version = "4.6.0" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" dependencies = [ "clap_builder", "clap_derive", @@ -457,27 +506,27 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.0" +version = "4.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "clap_lex" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "colored" @@ -485,7 +534,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]] @@ -493,6 +542,20 @@ name = "comments" version = "0.0.0" source = "git+https://github.com/etiennebacher/air_pratt#f29d5399f481205fb915f9453633729cbf529aa2" +[[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" @@ -501,7 +564,7 @@ checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" dependencies = [ "encode_unicode", "libc", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -562,13 +625,13 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -585,9 +648,9 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "encode_unicode" @@ -613,7 +676,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -629,7 +692,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -639,14 +702,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" dependencies = [ "cfg-if", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] name = "fastrand" -version = "2.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +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" @@ -660,6 +733,12 @@ 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" @@ -679,15 +758,13 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.1" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", "r-efi", - "wasip2", - "wasip3", ] [[package]] @@ -715,14 +792,28 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash", + "foldhash 0.1.5", ] [[package]] name = "hashbrown" -version = "0.16.1" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +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" @@ -741,12 +832,13 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "icu_collections" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ "displaydoc", "potential_utf", + "utf8_iter", "yoke", "zerofrom", "zerovec", @@ -754,9 +846,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ "displaydoc", "litemap", @@ -767,9 +859,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -781,15 +873,15 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] name = "icu_properties" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ "icu_collections", "icu_locale_core", @@ -801,15 +893,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.2" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] name = "icu_provider" -version = "2.1.1" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ "displaydoc", "icu_locale_core", @@ -839,9 +931,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -849,9 +941,9 @@ dependencies = [ [[package]] name = "ignore" -version = "0.4.25" +version = "0.4.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +checksum = "b915661dd01db3f05050265b2477bcc6527b3792388e2749b41623cc592be67d" dependencies = [ "crossbeam-deque", "globset", @@ -865,12 +957,12 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -890,6 +982,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" @@ -905,11 +1015,20 @@ 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" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jarl" @@ -936,6 +1055,7 @@ dependencies = [ name = "jarl-core" version = "0.6.0-alpha.4" dependencies = [ + "aether_path", "air_r_parser", "air_r_syntax", "annotate-snippets", @@ -946,16 +1066,26 @@ dependencies = [ "fs", "ignore", "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", "tempfile", "toml", "tracing", + "url", "workspace", ] @@ -977,6 +1107,19 @@ dependencies = [ "workspace", ] +[[package]] +name = "jarl-semantic" +version = "0.5.0" +dependencies = [ + "air_r_parser", + "air_r_syntax", + "biome_rowan", + "oak_core", + "oak_index_vec", + "oak_semantic", + "url", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -985,21 +1128,15 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "leb128" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "884e2677b40cc8c339eaefcb701c32ef1fd2493d71118dc0ca4b6a736c93bd67" - -[[package]] -name = "leb128fmt" -version = "0.1.0" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +checksum = "c83bff1d572d6b9aeef67ddfc8448e4a3737909cb28e81f97c791b9018703e52" [[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" @@ -1018,15 +1155,24 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +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.29" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" [[package]] name = "lsp-server" @@ -1056,9 +1202,18 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.0" +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 = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] [[package]] name = "miniz_oxide" @@ -1075,7 +1230,120 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys", +] + +[[package]] +name = "oak_core" +version = "0.1.0" +source = "git+https://github.com/etiennebacher/ark?branch=bump-air-commit#d78217c897f1e58ba157c17f672432e8d431b888" +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#d78217c897f1e58ba157c17f672432e8d431b888" +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#d78217c897f1e58ba157c17f672432e8d431b888" +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#d78217c897f1e58ba157c17f672432e8d431b888" +dependencies = [ + "biome_text_size", + "oak_core", +] + +[[package]] +name = "oak_package_metadata" +version = "0.1.0" +source = "git+https://github.com/etiennebacher/ark?branch=bump-air-commit#d78217c897f1e58ba157c17f672432e8d431b888" +dependencies = [ + "air_r_parser", + "air_r_syntax", + "anyhow", + "biome_rowan", + "oak_core", + "regex", + "stdext", +] + +[[package]] +name = "oak_scan" +version = "0.1.0" +source = "git+https://github.com/etiennebacher/ark?branch=bump-air-commit#d78217c897f1e58ba157c17f672432e8d431b888" +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#d78217c897f1e58ba157c17f672432e8d431b888" +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", + "rustc-hash", + "salsa", + "smallvec", + "stdext", + "url", ] [[package]] @@ -1089,9 +1357,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "once_cell_polyfill" @@ -1099,6 +1367,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" @@ -1125,27 +1416,23 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pin-project-lite" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] -name = "potential_utf" -version = "0.1.4" +name = "portable-atomic" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" -dependencies = [ - "zerovec", -] +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] -name = "prettyplease" -version = "0.2.37" +name = "potential_utf" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ - "proc-macro2", - "syn 2.0.117", + "zerovec", ] [[package]] @@ -1199,9 +1486,9 @@ dependencies = [ [[package]] name = "r-efi" -version = "5.3.0" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rayon" @@ -1223,6 +1510,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" @@ -1240,14 +1536,14 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "regex" -version = "1.12.3" +version = "1.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" dependencies = [ "aho-corasick", "memchr", @@ -1268,9 +1564,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.9" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "rustc-demangle" @@ -1280,9 +1576,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" @@ -1290,11 +1586,68 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "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]] @@ -1328,14 +1681,20 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.117", + "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.27" +version = "1.0.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" @@ -1364,7 +1723,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1375,14 +1734,14 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -1399,7 +1758,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1447,9 +1806,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "spdx" @@ -1476,7 +1835,22 @@ dependencies = [ "cfg-if", "libc", "psm", - "windows-sys 0.61.2", + "windows-sys", +] + +[[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#d78217c897f1e58ba157c17f672432e8d431b888" +dependencies = [ + "anyhow", + "log", ] [[package]] @@ -1507,9 +1881,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.117" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", @@ -1524,7 +1898,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1537,7 +1911,7 @@ dependencies = [ "getrandom", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -1551,14 +1925,20 @@ dependencies = [ [[package]] name = "terminal_size" -version = "0.4.3" +version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60b8cb979cb11c32ce1603f8137b22262a9d131aaa5c37b5678025f22b8becd0" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ "rustix", - "windows-sys 0.60.2", + "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" @@ -1576,7 +1956,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1590,9 +1970,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" dependencies = [ "displaydoc", "zerovec", @@ -1658,7 +2038,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] @@ -1684,9 +2064,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.22" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ "nu-ansi-term", "sharded-slab", @@ -1696,6 +2076,12 @@ dependencies = [ "tracing-log", ] +[[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" @@ -1710,9 +2096,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-width" @@ -1782,24 +2168,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "wasip2" -version = "1.0.2+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" -dependencies = [ - "wit-bindgen 0.51.0", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" -dependencies = [ - "wit-bindgen 0.51.0", -] - [[package]] name = "wasm-encoder" version = "0.201.0" @@ -1809,16 +2177,6 @@ dependencies = [ "leb128", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser 0.244.0", -] - [[package]] name = "wasm-metadata" version = "0.201.0" @@ -1831,20 +2189,8 @@ dependencies = [ "serde_derive", "serde_json", "spdx", - "wasm-encoder 0.201.0", - "wasmparser 0.201.0", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder 0.244.0", - "wasmparser 0.244.0", + "wasm-encoder", + "wasmparser", ] [[package]] @@ -1853,19 +2199,7 @@ version = "0.201.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84e5df6dba6c0d7fafc63a450f1738451ed7a0b52295d83e868218fa286bf708" dependencies = [ - "bitflags 2.11.0", - "indexmap", - "semver", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.11.0", - "hashbrown 0.15.5", + "bitflags 2.13.0", "indexmap", "semver", ] @@ -1876,7 +2210,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -1885,15 +2219,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -1903,76 +2228,11 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.53.5" -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", -] - -[[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.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - -[[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.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - -[[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.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - -[[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.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" -version = "0.7.14" +version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" dependencies = [ "memchr", ] @@ -1983,18 +2243,9 @@ version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "288f992ea30e6b5c531b52cdd5f3be81c148554b09ea416f058d16556ba92c27" dependencies = [ - "bitflags 2.11.0", + "bitflags 2.13.0", "wit-bindgen-rt", - "wit-bindgen-rust-macro 0.22.0", -] - -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro 0.51.0", + "wit-bindgen-rust-macro", ] [[package]] @@ -2004,18 +2255,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e85e72719ffbccf279359ad071497e47eb0675fe22106dea4ed2d8a7fcb60ba4" dependencies = [ "anyhow", - "wit-parser 0.201.0", -] - -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck 0.5.0", - "wit-parser 0.244.0", + "wit-parser", ] [[package]] @@ -2033,25 +2273,9 @@ dependencies = [ "anyhow", "heck 0.4.1", "indexmap", - "wasm-metadata 0.201.0", - "wit-bindgen-core 0.22.0", - "wit-component 0.201.0", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck 0.5.0", - "indexmap", - "prettyplease", - "syn 2.0.117", - "wasm-metadata 0.244.0", - "wit-bindgen-core 0.51.0", - "wit-component 0.244.0", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", ] [[package]] @@ -2063,24 +2287,9 @@ dependencies = [ "anyhow", "proc-macro2", "quote", - "syn 2.0.117", - "wit-bindgen-core 0.22.0", - "wit-bindgen-rust 0.22.0", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.117", - "wit-bindgen-core 0.51.0", - "wit-bindgen-rust 0.51.0", + "syn 2.0.118", + "wit-bindgen-core", + "wit-bindgen-rust", ] [[package]] @@ -2090,35 +2299,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "421c0c848a0660a8c22e2fd217929a0191f14476b68962afd2af89fd22e39825" dependencies = [ "anyhow", - "bitflags 2.11.0", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder 0.201.0", - "wasm-metadata 0.201.0", - "wasmparser 0.201.0", - "wit-parser 0.201.0", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.11.0", + "bitflags 2.13.0", "indexmap", "log", "serde", "serde_derive", "serde_json", - "wasm-encoder 0.244.0", - "wasm-metadata 0.244.0", - "wasmparser 0.244.0", - "wit-parser 0.244.0", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", ] [[package]] @@ -2136,25 +2326,7 @@ dependencies = [ "serde_derive", "serde_json", "unicode-xid", - "wasmparser 0.201.0", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser 0.244.0", + "wasmparser", ] [[package]] @@ -2179,9 +2351,9 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] name = "xtask" @@ -2204,9 +2376,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -2215,13 +2387,13 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "synstructure", ] @@ -2233,7 +2405,7 @@ checksum = "9fd16b8b30a9dc920fc1678ff852f696b5bdf5b5843bc745a128be0aac29859e" dependencies = [ "serde", "serde_json", - "wit-bindgen 0.22.0", + "wit-bindgen", ] [[package]] @@ -2245,30 +2417,30 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] [[package]] name = "zerofrom-derive" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", "synstructure", ] [[package]] name = "zerotrie" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" dependencies = [ "displaydoc", "yoke", @@ -2277,9 +2449,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" dependencies = [ "yoke", "zerofrom", @@ -2288,13 +2460,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.118", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 0701c99ee..8e65ccaaa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,16 @@ air_fs = { package = "fs", git = "https://github.com/etiennebacher/air_pratt" } air_r_parser = { git = "https://github.com/etiennebacher/air_pratt" } air_r_syntax = { git = "https://github.com/etiennebacher/air_pratt" } air_workspace = { package = "workspace", git = "https://github.com/etiennebacher/air_pratt" } +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" } biome_rowan = { git = "https://github.com/lionel-/biome", rev = "a1296ea6ba363d8b8d8f02181b2a4ce9315c5ef9" } @@ -25,6 +35,7 @@ insta = { version = "1.47.2", features = ["yaml", "filters"] } 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"] } @@ -38,6 +49,13 @@ tempfile = "3.27.0" toml = "0.8.23" tracing = "0.1.41" +# The oak crates (from the ark fork) declare their air dependencies against +# posit-dev/air; redirect them to the same Pratt-parser fork used by jarl so a +# single copy of the syntax/parser crates exists in the graph. +[patch."https://github.com/posit-dev/air"] +air_r_parser = { git = "https://github.com/etiennebacher/air_pratt" } +air_r_syntax = { git = "https://github.com/etiennebacher/air_pratt" } + [profile.release] lto = true diff --git a/crates/jarl-core/Cargo.toml b/crates/jarl-core/Cargo.toml index 5be3f729b..3bcd20d9e 100644 --- a/crates/jarl-core/Cargo.toml +++ b/crates/jarl-core/Cargo.toml @@ -23,6 +23,21 @@ air_fs.workspace = true 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) +jarl-semantic.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 5d91849ec..c303872fe 100644 --- a/crates/jarl-core/src/analyze/document.rs +++ b/crates/jarl-core/src/analyze/document.rs @@ -1,10 +1,12 @@ use air_r_syntax::{RExpressionList, RSyntaxNode}; use biome_rowan::{AstNode, AstNodeList}; +use oak_semantic::semantic_index::SemanticIndex; use crate::checker::Checker; use crate::diagnostic::*; use crate::lints::base::empty_file::empty_file::empty_file; 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; @@ -15,14 +17,15 @@ 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 --- @@ -35,6 +38,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, &package.cross_file_used, checker)?; + } + // --- Comment/suppression checks --- // Report blanket suppression comments (file-level, done once) @@ -104,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(), @@ -118,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 7f077895b..885d1a3c3 100644 --- a/crates/jarl-core/src/check.rs +++ b/crates/jarl-core/src/check.rs @@ -1,14 +1,14 @@ 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; 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; @@ -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}"))?; @@ -164,6 +166,10 @@ pub fn lint_fix( &pkg, &pkg_contexts, &file_pkg_info, + // 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}",))?; @@ -200,6 +206,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); @@ -224,23 +231,48 @@ pub fn get_checks( checker.rule_set = effective_rules_for_file(config, file); checker.minimum_r_version = config.minimum_r_version; + // 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`. + // + // 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, - expressions, + semantic, config, pkg_contexts, file_pkg_info, ); // 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 @@ -274,8 +306,8 @@ pub fn get_checks( expressions, syntax, &mut checker, - &duplicate_assignments, - &unused_functions, + &package_file, + Some(semantic), )?; // Some rules have a fix available in their implementation but do not have @@ -335,11 +367,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, @@ -357,13 +390,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. @@ -403,7 +461,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, &[], &[])?; + check_document( + expressions, + &syntax, + &mut checker, + &PackageFileAnalysis::default(), + None, + )?; } for mut d in checker.diagnostics { @@ -453,8 +517,14 @@ fn get_checks_rmd(contents: &str, file: &Path, config: &Config) -> Result = checker 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/db.rs b/crates/jarl-core/src/db.rs new file mode 100644 index 000000000..ef98f5811 --- /dev/null +++ b/crates/jarl-core/src/db.rs @@ -0,0 +1,415 @@ +//! 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`. 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 +//! 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::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +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::{DefinitionKind, SemanticIndex}; +use rayon::prelude::*; + +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 { + db: OakDatabase, +} + +/// One file's contribution to cross-file resolution: the names it binds at +/// 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, 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. + pub indices: HashMap>, +} + +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) + } + + /// 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 + } + + /// 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 + /// 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. + /// + /// `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. + /// + /// 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, 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. 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)), 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 + // 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, in_package)| { + let source = std::fs::read_to_string(path).ok()?; + let parsed = air_r_parser::parse(&source, RParserOptions::default()); + if parsed.has_error() { + return None; + } + let index = oak_semantic::build_index( + &parsed.tree(), + jarl_semantic::JarlImportsResolver::new(path.clone()), + ); + let uses = collect_file_uses(path.clone(), &index, *in_package); + 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> = built + .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 &built { + for name in &file.free_uses { + if candidates.contains(name.as_str()) { + readers_of.entry(name).or_default().push(&file.path); + } + } + } + + // A top-level definition is used when some *other* file reads its name. + let mut used: HashMap> = HashMap::new(); + for (_, file) in &built { + 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()); + } + } + } + + // `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))) + .collect(); + CrossFileAnalysis { used, indices } + } +} + +/// 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. +/// +/// 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() { + 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()); + } + } + } + + 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 +/// 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| { + let absolute = if path.is_absolute() { + path.clone() + } else { + cwd.as_ref()?.join(path) + }; + find_package_root(&absolute) + }) + .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/description.rs b/crates/jarl-core/src/description.rs index 79a874bf9..50f6b33ad 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,11 +17,11 @@ 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 { - 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(); @@ -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::*; diff --git a/crates/jarl-core/src/lib.rs b/crates/jarl-core/src/lib.rs index 0bc43ed5e..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; @@ -20,7 +21,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/glue/glue.rs b/crates/jarl-core/src/lints/base/glue/glue.rs index da0fbda37..4f99b918f 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/mod.rs b/crates/jarl-core/src/lints/base/mod.rs index b8034e129..1ccf48b48 100644 --- a/crates/jarl-core/src/lints/base/mod.rs +++ b/crates/jarl-core/src/lints/base/mod.rs @@ -55,5 +55,6 @@ pub(crate) mod unnecessary_nesting; pub(crate) mod unnecessary_parentheses; 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..1f615bb1a --- /dev/null +++ b/crates/jarl-core/src/lints/base/unused_object/mod.rs @@ -0,0 +1,1346 @@ +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) + } + + /// 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); + } + + #[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_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( + "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_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)"), @" + warning: unused_object + --> :1:1 + | + 1 | `%op%` <- 42 + | ------ Object `%op%` is defined but never used. + | + Found 1 error. + "); + } + + #[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_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_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_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 + // 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); + 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_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( + "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); + } + + #[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_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()", + "unused_object", + None, + ); + } + + #[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_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_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 + 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() { + // 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() { + // 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() { + // 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_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_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_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 + // 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)` + // 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( + " + 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, + ); + } + + #[test] + fn test_object_used_in_formula_is_used() { + expect_no_lint( + " + X <- 2 + lm(1 ~ X)", + "unused_object", + 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!"); + } +} 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..425f5ef72 --- /dev/null +++ b/crates/jarl-core/src/lints/base/unused_object/unused_object.rs @@ -0,0 +1,232 @@ +use air_r_syntax::{RBinaryExpression, RSyntaxKind, RSyntaxNode}; +use biome_rowan::{AstNode, SyntaxNodeCast}; +use oak_core::syntax_ext::RIdentifierExt; +use oak_semantic::semantic_index::{Definition, DefinitionKind, ScopeId, SemanticIndex}; + +use jarl_semantic::{ + SemanticInfo, assignment_lhs_is_complex, assignment_rhs_is_function_def, + lhs_range_for_definition, +}; + +use crate::checker::Checker; +use crate::diagnostic::{Diagnostic, Fix, ViolationData}; + +/// Version added: 0.6.0 +/// +/// ## What it does +/// +/// Detects objects that are defined (i.e. assigned a value) but never used. +/// +/// ## 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, + cross_file_used: &std::collections::HashSet, + checker: &mut Checker, +) -> anyhow::Result<()> { + 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(); + let top_level = ScopeId::from(0); + for &scope_id in &info.scope_ids() { + for (def_id, def) in semantic.definitions(scope_id).iter() { + if !should_lint_definition(&info, def) { + continue; + } + if info.is_definition_used(scope_id, def_id, def) { + continue; + } + // 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)) + { + continue; + } + diagnostics.push(make_diagnostic(semantic, scope_id, def, info.root())); + } + } + diagnostics.extend(collect_assignment_pipe_diagnostics( + expressions, + semantic, + &info, + exports, + cross_file_used, + )); + + for d in diagnostics { + checker.report_diagnostic(Some(d)); + } + + Ok(()) +} + +fn should_lint_definition(info: &SemanticInfo<'_>, def: &Definition) -> bool { + match def.kind() { + DefinitionKind::Parameter(_) + | DefinitionKind::ForVariable(_) + | DefinitionKind::SuperAssignment(_) + | DefinitionKind::Import { .. } => return false, + 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(&bin) { + return false; + } + } + } + + // `=` inside a formula RHS is named-arg syntax, not assignment. + if info.is_in_formula(def.range()) { + 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 +} + +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(def.symbol()).name(); + exports.contains(name) +} + +/// 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, + 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, + def: &Definition, + root: &RSyntaxNode, +) -> Diagnostic { + let name = semantic + .symbols(scope_id) + .symbol(def.symbol()) + .name() + .to_string(); + let range = lhs_range_for_definition(def, root).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(), + ) +} + +/// 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, + info: &SemanticInfo<'_>, + exports: &std::collections::HashSet, + cross_file_used: &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 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(); + + let (scope_id, _) = semantic.scope_at(lhs_range.start()); + + let symbol = semantic.symbols(scope_id).id(&name); + let later_use = symbol.is_some_and(|sym| { + semantic + .uses(scope_id) + .iter() + .any(|(_, u)| u.symbol() == sym && u.range().start() >= expr_end) + }); + let closure_use = info.is_used_in_nested_scope(scope_id, &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 && !cross_file { + out.push(Diagnostic::new( + ViolationData::new( + "unused_object".to_string(), + format!("Object `{name}` is defined but never used."), + None, + ), + lhs_range, + Fix::empty(), + )); + } + } + } + out +} diff --git a/crates/jarl-core/src/package.rs b/crates/jarl-core/src/package.rs index ec5becacf..d1644b2c2 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; @@ -80,6 +82,42 @@ 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. 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 + /// 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 +/// 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,102 +264,40 @@ 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(); } - // 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 @@ -387,7 +363,43 @@ pub fn make_package_analysis( HashMap::new() }; - PackageAnalysis { duplicate_assignments, unused_functions } + // 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(&loose_script_paths(paths)) + } else { + crate::db::CrossFileAnalysis::default() + }; + + PackageAnalysis { + duplicate_assignments, + unused_functions, + cross_file_used: cross_file.used, + file_indices: cross_file.indices, + } +} + +/// 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. @@ -414,6 +426,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-core/src/rule_set.rs b/crates/jarl-core/src/rule_set.rs index de2c36c0e..e9f3e8868 100644 --- a/crates/jarl-core/src/rule_set.rs +++ b/crates/jarl-core/src/rule_set.rs @@ -643,6 +643,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], 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 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-semantic/Cargo.toml b/crates/jarl-semantic/Cargo.toml new file mode 100644 index 000000000..ad43487da --- /dev/null +++ b/crates/jarl-semantic/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "jarl-semantic" +description = "Per-file semantic info 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_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 new file mode 100644 index 000000000..85f99fdea --- /dev/null +++ b/crates/jarl-semantic/src/lib.rs @@ -0,0 +1,1098 @@ +//! Per-file semantic info for jarl lint rules. +//! +//! `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 +//! `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; +use air_r_syntax::{ + AnyRArgumentName, AnyRExpression, RArgument, RArgumentList, RBinaryExpression, RCall, + RExtractExpression, RNamespaceExpression, RStringValue, RSyntaxKind, RSyntaxNode, +}; +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}; + +/// 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, + /// 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, + /// Ranges inside an NSE argument that are nonetheless evaluated and so + /// carve a hole back out of [`Self::nse_ranges`]: the `.()` operands of + /// `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, + /// Ranges of formula RHSes (`~ rhs`). + formula_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 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], + index: &'a SemanticIndex, + file: &'a std::path::Path, + ) -> Self { + let mut this = Self { + index, + 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(), + formula_ranges: Vec::new(), + reaching_used: HashSet::new(), + }; + this.collect_ast_passes(expressions); + let scopes = this.scope_ids(); + this.precompute_reaching_uses(&scopes); + this.precompute_interpolation_uses(); + this + } + + pub fn index(&self) -> &SemanticIndex { + self.index + } + + pub fn root(&self) -> &RSyntaxNode { + &self.root + } + + /// Walk all scopes (root + descendants) in arbitrary order. + pub fn scope_ids(&self) -> Vec { + 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, a reaching use (local or via a + /// nested closure), or the `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(def.symbol()).name(); + if self.synthetic_used_names.contains(symbol_name) { + return true; + } + if self.reaching_used.contains(&(scope_id, def_id)) { + 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) + } + + /// 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.unquote_ranges) + } + + pub fn has_synthetic_use(&self, name: &str) -> bool { + self.synthetic_used_names.contains(name) + } + + /// 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 ──────────────────────────────────────────── + + 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 text = node.text_trimmed().to_string(); + // 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(&text) { + self.collect_cli_interpolation(&content, read_range); + } + 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 + // `collect_custom_glue_interpolation`. + for segment in scan_interpolation_segments(&text, "{", "}") { + self.collect_identifiers_in_interpolation(segment, read_range); + } + } + + /// 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, read_range: TextRange) { + for segment in scan_interpolation_segments(content, "{", "}") { + if let Some(inner) = cli_markup_content(segment) { + self.collect_cli_interpolation(inner, read_range); + } else { + self.collect_identifiers_in_interpolation(segment, read_range); + } + } + } + + /// 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; + } + let Some(content) = + strings::get_string_literal_contents(&value.text_trimmed().to_string()) + else { + continue; + }; + let read_range = value.text_trimmed_range(); + for segment in scan_interpolation_segments(&content, &open, &close) { + self.collect_identifiers_in_interpolation(segment, read_range); + } + } + } + + /// Parse a glue-style `{...}` interpolation as R code and collect every + /// 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; + } + 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.interpolation_uses + .push((token.text_trimmed().to_string(), read_range)); + } + } + } + + 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. 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()); + 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 + // 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) { + let Some(name) = call_name(call) else { + return; + }; + + 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 + // `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(...)` 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()); + } + } + // `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, 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" => { + 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() + && 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); + } + } + "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 + && let Some(token) = node.first_token() + { + self.synthetic_used_names + .insert(token.text_trimmed().to_string()); + } + } + } + + /// 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. + /// + /// `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 self.is_in_nse(u.range()) { + continue; + } + for (def_scope, def_id) in index.reaching_definitions(scope_id, use_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) + } + + /// 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(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, 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. + 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; + } + let captured = owner != read_scope; + for (def_id, start) in defs { + if captured || start < pos { + 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; + } + 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_scope_ids(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)) +} + +/// 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 — 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() { + 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..]; + // 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) { + 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.text_trimmed().to_string()) +} + +/// 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; + }; + 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 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() { + 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 +} + +/// 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(bin: &RBinaryExpression) -> bool { + 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, 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 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 + } +} + +/// 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 { + 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 { + node.clone().cast::()?.string_text() +} + +/// Resolve a `source("path")` argument against the currently-analyzed file. +/// +/// 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()); + } + 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 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 +/// 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, + /// Files already resolved along this `source()` chain (absolutized), + /// the analyzed file included. Shared across the whole chain so cyclic + /// `source()` graphs terminate: a file is resolved at most once per + /// chain, and a repeat contributes no names (mirroring oak_db's + /// cycle-recovery on `File::exports`). + visited: std::rc::Rc>>, +} + +impl JarlImportsResolver { + pub fn new(current_file: impl Into) -> Self { + 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 exotic paths still index. + let url = url::Url::from_file_path(&target_key) + .ok() + .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-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) +} 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/exclude.rs b/crates/jarl/tests/integration/exclude.rs index 3d6d9830e..73d3b6e51 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"), @@ -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"), @@ -600,7 +600,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"), @@ -637,7 +637,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"), @@ -685,7 +685,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"), @@ -720,7 +720,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"), ( diff --git a/crates/jarl/tests/integration/main.rs b/crates/jarl/tests/integration/main.rs index 8be8b09bd..e1ee76001 100644 --- a/crates/jarl/tests/integration/main.rs +++ b/crates/jarl/tests/integration/main.rs @@ -28,3 +28,4 @@ mod statistics; mod toml; mod toml_hierarchical; mod toml_rule_args; +mod unused_object; 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 diff --git a/crates/jarl/tests/integration/unused_object.rs b/crates/jarl/tests/integration/unused_object.rs new file mode 100644 index 000000000..5fb03cf57 --- /dev/null +++ b/crates/jarl/tests/integration/unused_object.rs @@ -0,0 +1,422 @@ +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_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_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. + 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(()) +}