diff --git a/.gitattributes b/.gitattributes index 0e0276a958df8..a1451e6b491a5 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,3 +3,4 @@ testdata/cheats/Vm.sol linguist-generated # See *.rs diff=rust +crates/lint/testdata/* text eol=lf diff --git a/Cargo.lock b/Cargo.lock index 6408030f5fc6c..8ece12fa473aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -62,7 +62,7 @@ version = "0.1.69" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28e2652684758b0d9b389d248b209ed9fd9989ef489a550265fe4bb8454fe7eb" dependencies = [ - "alloy-primitives", + "alloy-primitives 0.8.25", "num_enum", "serde", "strum 0.27.1", @@ -75,7 +75,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6fbf458101ed6c389e9bb70a34ebc56039868ad10472540614816cdedc8f5265" dependencies = [ "alloy-eips", - "alloy-primitives", + "alloy-primitives 0.8.25", "alloy-rlp", "alloy-serde", "alloy-trie", @@ -99,7 +99,7 @@ checksum = "fc982af629e511292310fe85b433427fd38cb3105147632b574abc997db44c91" dependencies = [ "alloy-consensus", "alloy-eips", - "alloy-primitives", + "alloy-primitives 0.8.25", "alloy-rlp", "alloy-serde", "serde", @@ -113,10 +113,10 @@ checksum = "cd0a0c1ddee20ecc14308aae21c2438c994df7b39010c26d70f86e1d8fdb8db0" dependencies = [ "alloy-consensus", "alloy-dyn-abi", - "alloy-json-abi", + "alloy-json-abi 0.8.25", "alloy-network", "alloy-network-primitives", - "alloy-primitives", + "alloy-primitives 0.8.25", "alloy-provider", "alloy-pubsub", "alloy-rpc-types-eth", @@ -133,9 +133,9 @@ version = "0.8.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb8e762aefd39a397ff485bc86df673465c4ad3ec8819cc60833a8a3ba5cdc87" dependencies = [ - "alloy-json-abi", - "alloy-primitives", - "alloy-sol-type-parser", + "alloy-json-abi 0.8.25", + "alloy-primitives 0.8.25", + "alloy-sol-type-parser 0.8.25", "alloy-sol-types", "arbitrary", "const-hex", @@ -154,7 +154,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "675264c957689f0fd75f5993a73123c2cc3b5c235a38f5b9037fe6c826bfb2c0" dependencies = [ - "alloy-primitives", + "alloy-primitives 0.8.25", "alloy-rlp", "crc", "serde", @@ -167,7 +167,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0069cf0642457f87a01a014f6dc29d5d893cd4fd8fddf0c3cdfad1bb3ebafc41" dependencies = [ - "alloy-primitives", + "alloy-primitives 0.8.25", "alloy-rlp", "arbitrary", "rand 0.8.5", @@ -180,7 +180,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b15b13d38b366d01e818fe8e710d4d702ef7499eacd44926a06171dd9585d0c" dependencies = [ - "alloy-primitives", + "alloy-primitives 0.8.25", "alloy-rlp", "arbitrary", "k256", @@ -198,7 +198,7 @@ dependencies = [ "alloy-eip2124", "alloy-eip2930", "alloy-eip7702", - "alloy-primitives", + "alloy-primitives 0.8.25", "alloy-rlp", "alloy-serde", "auto_impl", @@ -217,7 +217,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a40de6f5b53ecf5fd7756072942f41335426d9a3704cd961f77d854739933bcf" dependencies = [ "alloy-eips", - "alloy-primitives", + "alloy-primitives 0.8.25", "alloy-serde", "alloy-trie", "serde", @@ -229,8 +229,20 @@ version = "0.8.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe6beff64ad0aa6ad1019a3db26fef565aefeb011736150ab73ed3366c3cfd1b" dependencies = [ - "alloy-primitives", - "alloy-sol-type-parser", + "alloy-primitives 0.8.25", + "alloy-sol-type-parser 0.8.25", + "serde", + "serde_json", +] + +[[package]] +name = "alloy-json-abi" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0068ae277f5ee3153a95eaea8ff10e188ed8ccde9b7f9926305415a2c0ab2442" +dependencies = [ + "alloy-primitives 1.1.0", + "alloy-sol-type-parser 1.1.0", "serde", "serde_json", ] @@ -241,7 +253,7 @@ version = "0.12.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27434beae2514d4a2aa90f53832cbdf6f23e4b5e2656d95eaf15f9276e2418b6" dependencies = [ - "alloy-primitives", + "alloy-primitives 0.8.25", "alloy-sol-types", "serde", "serde_json", @@ -260,7 +272,7 @@ dependencies = [ "alloy-eips", "alloy-json-rpc", "alloy-network-primitives", - "alloy-primitives", + "alloy-primitives 0.8.25", "alloy-rpc-types-any", "alloy-rpc-types-eth", "alloy-serde", @@ -283,7 +295,7 @@ checksum = "db973a7a23cbe96f2958e5687c51ce2d304b5c6d0dc5ccb3de8667ad8476f50b" dependencies = [ "alloy-consensus", "alloy-eips", - "alloy-primitives", + "alloy-primitives 0.8.25", "alloy-serde", "serde", ] @@ -319,6 +331,33 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "alloy-primitives" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a12fe11d0b8118e551c29e1a67ccb6d01cc07ef08086df30f07487146de6fa1" +dependencies = [ + "alloy-rlp", + "bytes", + "cfg-if", + "const-hex", + "derive_more 2.0.1", + "foldhash", + "hashbrown 0.15.2", + "indexmap 2.9.0", + "itoa", + "k256", + "keccak-asm", + "paste", + "proptest", + "rand 0.9.1", + "ruint", + "rustc-hash 2.1.1", + "serde", + "sha3", + "tiny-keccak", +] + [[package]] name = "alloy-provider" version = "0.12.6" @@ -331,7 +370,7 @@ dependencies = [ "alloy-json-rpc", "alloy-network", "alloy-network-primitives", - "alloy-primitives", + "alloy-primitives 0.8.25", "alloy-pubsub", "alloy-rpc-client", "alloy-rpc-types-debug", @@ -369,7 +408,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "721aca709a9231815ad5903a2d284042cc77e7d9d382696451b30c9ee0950001" dependencies = [ "alloy-json-rpc", - "alloy-primitives", + "alloy-primitives 0.8.25", "alloy-transport", "bimap", "futures", @@ -410,7 +449,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "445a3298c14fae7afb5b9f2f735dead989f3dd83020c2ab8e48ed95d7b6d1acb" dependencies = [ "alloy-json-rpc", - "alloy-primitives", + "alloy-primitives 0.8.25", "alloy-pubsub", "alloy-transport", "alloy-transport-http", @@ -437,7 +476,7 @@ version = "0.12.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9157deaec6ba2ad7854f16146e4cd60280e76593eed79fdcb06e0fa8b6c60f77" dependencies = [ - "alloy-primitives", + "alloy-primitives 0.8.25", "alloy-rpc-types-anvil", "alloy-rpc-types-engine", "alloy-rpc-types-eth", @@ -453,7 +492,7 @@ version = "0.12.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a80ee83ef97e7ffd667a81ebdb6154558dfd5e8f20d8249a10a12a1671a04b3" dependencies = [ - "alloy-primitives", + "alloy-primitives 0.8.25", "alloy-rpc-types-eth", "alloy-serde", "serde", @@ -476,7 +515,7 @@ version = "0.12.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08b113a0087d226291b9768ed331818fa0b0744cc1207ae7c150687cf3fde1bd" dependencies = [ - "alloy-primitives", + "alloy-primitives 0.8.25", "serde", ] @@ -488,7 +527,7 @@ checksum = "874ac9d1249ece0453e262d9ba72da9dbb3b7a2866220ded5940c2e47f1aa04d" dependencies = [ "alloy-consensus", "alloy-eips", - "alloy-primitives", + "alloy-primitives 0.8.25", "alloy-rlp", "alloy-serde", "derive_more 2.0.1", @@ -508,7 +547,7 @@ dependencies = [ "alloy-consensus-any", "alloy-eips", "alloy-network-primitives", - "alloy-primitives", + "alloy-primitives 0.8.25", "alloy-rlp", "alloy-serde", "alloy-sol-types", @@ -524,7 +563,7 @@ version = "0.12.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4747763aee39c1b0f5face79bde9be8932be05b2db7d8bdcebb93490f32c889c" dependencies = [ - "alloy-primitives", + "alloy-primitives 0.8.25", "alloy-rpc-types-eth", "alloy-serde", "serde", @@ -538,7 +577,7 @@ version = "0.12.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70132ebdbea1eaa68c4d6f7a62c2fadf0bdce83b904f895ab90ca4ec96f63468" dependencies = [ - "alloy-primitives", + "alloy-primitives 0.8.25", "alloy-rpc-types-eth", "alloy-serde", "serde", @@ -550,7 +589,7 @@ version = "0.12.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a1cd73fc054de6353c7f22ff9b846b0f0f145cd0112da07d4119e41e9959207" dependencies = [ - "alloy-primitives", + "alloy-primitives 0.8.25", "serde", "serde_json", ] @@ -562,7 +601,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c96fbde54bee943cd94ebacc8a62c50b38c7dfd2552dcd79ff61aea778b1bfcc" dependencies = [ "alloy-dyn-abi", - "alloy-primitives", + "alloy-primitives 0.8.25", "alloy-sol-types", "async-trait", "auto_impl", @@ -580,7 +619,7 @@ checksum = "4e73835ed6689740b76cab0f59afbdce374a03d3f856ea33ba1fc054630a1b28" dependencies = [ "alloy-consensus", "alloy-network", - "alloy-primitives", + "alloy-primitives 0.8.25", "alloy-signer", "async-trait", "aws-sdk-kms", @@ -598,7 +637,7 @@ checksum = "a16b468ae86bb876d9c7a3b49b1e8d614a581a1a9673e4e0d2393b411080fe64" dependencies = [ "alloy-consensus", "alloy-network", - "alloy-primitives", + "alloy-primitives 0.8.25", "alloy-signer", "async-trait", "gcloud-sdk", @@ -617,7 +656,7 @@ dependencies = [ "alloy-consensus", "alloy-dyn-abi", "alloy-network", - "alloy-primitives", + "alloy-primitives 0.8.25", "alloy-signer", "alloy-sol-types", "async-trait", @@ -636,7 +675,7 @@ checksum = "cc6e72002cc1801d8b41e9892165e3a6551b7bd382bd9d0414b21e90c0c62551" dependencies = [ "alloy-consensus", "alloy-network", - "alloy-primitives", + "alloy-primitives 0.8.25", "alloy-signer", "async-trait", "coins-bip32", @@ -655,7 +694,7 @@ checksum = "1d4fd403c53cf7924c3e16c61955742cfc3813188f0975622f4fa6f8a01760aa" dependencies = [ "alloy-consensus", "alloy-network", - "alloy-primitives", + "alloy-primitives 0.8.25", "alloy-signer", "async-trait", "semver 1.0.26", @@ -684,7 +723,7 @@ version = "0.8.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83ad5da86c127751bc607c174d6c9fe9b85ef0889a9ca0c641735d77d4f98f26" dependencies = [ - "alloy-json-abi", + "alloy-json-abi 0.8.25", "alloy-sol-macro-input", "const-hex", "heck", @@ -703,7 +742,7 @@ version = "0.8.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba3d30f0d3f9ba3b7686f3ff1de9ee312647aac705604417a2f40c604f409a9e" dependencies = [ - "alloy-json-abi", + "alloy-json-abi 0.8.25", "const-hex", "dunce", "heck", @@ -725,14 +764,24 @@ dependencies = [ "winnow 0.7.9", ] +[[package]] +name = "alloy-sol-type-parser" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "251273c5aa1abb590852f795c938730fa641832fc8fa77b5478ed1bf11b6097e" +dependencies = [ + "serde", + "winnow 0.7.7", +] + [[package]] name = "alloy-sol-types" version = "0.8.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d43d5e60466a440230c07761aa67671d4719d46f43be8ea6e7ed334d8db4a9ab" dependencies = [ - "alloy-json-abi", - "alloy-primitives", + "alloy-json-abi 0.8.25", + "alloy-primitives 0.8.25", "alloy-sol-macro", "const-hex", "serde", @@ -819,7 +868,7 @@ version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d95a94854e420f07e962f7807485856cde359ab99ab6413883e15235ad996e8b" dependencies = [ - "alloy-primitives", + "alloy-primitives 0.8.25", "alloy-rlp", "arrayvec", "derive_more 1.0.0", @@ -951,7 +1000,7 @@ dependencies = [ "alloy-eips", "alloy-genesis", "alloy-network", - "alloy-primitives", + "alloy-primitives 0.8.25", "alloy-provider", "alloy-pubsub", "alloy-rlp", @@ -1008,7 +1057,7 @@ dependencies = [ "alloy-dyn-abi", "alloy-eips", "alloy-network", - "alloy-primitives", + "alloy-primitives 0.8.25", "alloy-rlp", "alloy-rpc-types", "alloy-serde", @@ -2062,6 +2111,38 @@ dependencies = [ "serde", ] +[[package]] +name = "camino" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b96ec4966b5813e2c0507c1f86115c8c5abaadc3980879c3424042a02fd1ad3" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d886547e41f740c616ae73108f6eb70afe6d940c7bc697cb30f13daec073037" +dependencies = [ + "camino", + "cargo-platform", + "semver 1.0.26", + "serde", + "serde_json", + "thiserror 1.0.69", +] + [[package]] name = "cassowary" version = "0.3.0" @@ -2076,10 +2157,10 @@ dependencies = [ "alloy-consensus", "alloy-contract", "alloy-dyn-abi", - "alloy-json-abi", + "alloy-json-abi 0.8.25", "alloy-json-rpc", "alloy-network", - "alloy-primitives", + "alloy-primitives 0.8.25", "alloy-provider", "alloy-rlp", "alloy-rpc-types", @@ -2169,8 +2250,8 @@ name = "chisel" version = "1.1.0" dependencies = [ "alloy-dyn-abi", - "alloy-json-abi", - "alloy-primitives", + "alloy-json-abi 0.8.25", + "alloy-primitives 0.8.25", "clap", "dirs", "eyre", @@ -2484,6 +2565,16 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990" +[[package]] +name = "colored" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +dependencies = [ + "lazy_static", + "windows-sys 0.59.0", +] + [[package]] name = "comfy-table" version = "7.1.4" @@ -2495,6 +2586,12 @@ dependencies = [ "unicode-width 0.2.0", ] +[[package]] +name = "comma" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55b672471b4e9f9e95499ea597ff64941a309b2cdbffcc46f2cc5e2d971fd335" + [[package]] name = "compact_str" version = "0.8.1" @@ -2648,6 +2745,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -3295,7 +3401,7 @@ checksum = "78329cbf3c326a3ce2694003976c019fe5f407682b1fdc76e89e463826ea511a" dependencies = [ "ahash", "alloy-dyn-abi", - "alloy-primitives", + "alloy-primitives 0.8.25", "indexmap 2.9.0", ] @@ -3451,9 +3557,9 @@ version = "1.1.0" dependencies = [ "alloy-chains", "alloy-dyn-abi", - "alloy-json-abi", + "alloy-json-abi 0.8.25", "alloy-network", - "alloy-primitives", + "alloy-primitives 0.8.25", "alloy-provider", "alloy-rpc-types", "alloy-serde", @@ -3473,6 +3579,7 @@ dependencies = [ "eyre", "forge-doc", "forge-fmt", + "forge-lint", "forge-script", "forge-script-sequence", "forge-sol-macro-gen", @@ -3508,6 +3615,7 @@ dependencies = [ "similar", "similar-asserts", "solang-parser", + "solar-interface", "solar-parse", "soldeer-commands", "strum 0.27.1", @@ -3530,7 +3638,7 @@ dependencies = [ name = "forge-doc" version = "1.1.0" dependencies = [ - "alloy-primitives", + "alloy-primitives 0.8.25", "derive_more 2.0.1", "eyre", "forge-fmt", @@ -3553,7 +3661,7 @@ dependencies = [ name = "forge-fmt" version = "1.1.0" dependencies = [ - "alloy-primitives", + "alloy-primitives 0.8.25", "ariadne", "foundry-config", "itertools 0.14.0", @@ -3565,6 +3673,20 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "forge-lint" +version = "1.1.0" +dependencies = [ + "foundry-compilers", + "foundry-config", + "heck", + "rayon", + "solar-ast", + "solar-interface", + "solar-parse", + "thiserror 2.0.12", +] + [[package]] name = "forge-script" version = "1.1.0" @@ -3573,9 +3695,9 @@ dependencies = [ "alloy-consensus", "alloy-dyn-abi", "alloy-eips", - "alloy-json-abi", + "alloy-json-abi 0.8.25", "alloy-network", - "alloy-primitives", + "alloy-primitives 0.8.25", "alloy-provider", "alloy-rpc-types", "alloy-serde", @@ -3615,7 +3737,7 @@ name = "forge-script-sequence" version = "1.1.0" dependencies = [ "alloy-network", - "alloy-primitives", + "alloy-primitives 0.8.25", "eyre", "foundry-common", "foundry-compilers", @@ -3646,8 +3768,8 @@ name = "forge-verify" version = "1.1.0" dependencies = [ "alloy-dyn-abi", - "alloy-json-abi", - "alloy-primitives", + "alloy-json-abi 0.8.25", + "alloy-primitives 0.8.25", "alloy-provider", "alloy-rpc-types", "async-trait", @@ -3690,8 +3812,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8025385c52416bf14e5bb28d21eb5efe2490dd6fb001a49b87f1825a626b4909" dependencies = [ "alloy-chains", - "alloy-json-abi", - "alloy-primitives", + "alloy-json-abi 0.8.25", + "alloy-primitives 0.8.25", "foundry-compilers", "reqwest", "semver 1.0.26", @@ -3709,9 +3831,9 @@ dependencies = [ "alloy-consensus", "alloy-dyn-abi", "alloy-genesis", - "alloy-json-abi", + "alloy-json-abi 0.8.25", "alloy-network", - "alloy-primitives", + "alloy-primitives 0.8.25", "alloy-provider", "alloy-rlp", "alloy-rpc-types", @@ -3767,8 +3889,8 @@ dependencies = [ "alloy-chains", "alloy-dyn-abi", "alloy-eips", - "alloy-json-abi", - "alloy-primitives", + "alloy-json-abi 0.8.25", + "alloy-primitives 0.8.25", "alloy-provider", "alloy-rlp", "clap", @@ -3809,10 +3931,10 @@ dependencies = [ "alloy-contract", "alloy-dyn-abi", "alloy-eips", - "alloy-json-abi", + "alloy-json-abi 0.8.25", "alloy-json-rpc", "alloy-network", - "alloy-primitives", + "alloy-primitives 0.8.25", "alloy-provider", "alloy-pubsub", "alloy-rpc-client", @@ -3865,7 +3987,7 @@ dependencies = [ "alloy-consensus", "alloy-dyn-abi", "alloy-network", - "alloy-primitives", + "alloy-primitives 0.8.25", "alloy-rpc-types", "alloy-serde", "chrono", @@ -3884,8 +4006,8 @@ version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94bb4155f53d4b05642a1398ad105dc04d44b368a7932b85f6ed012af48768b7" dependencies = [ - "alloy-json-abi", - "alloy-primitives", + "alloy-json-abi 0.8.25", + "alloy-primitives 0.8.25", "auto_impl", "derive_more 1.0.0", "dirs", @@ -3931,8 +4053,8 @@ version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "99d5c80fda7c4fde0d2964b329b22d09718838da0c940e5df418f2c1db14fd24" dependencies = [ - "alloy-json-abi", - "alloy-primitives", + "alloy-json-abi 0.8.25", + "alloy-primitives 0.8.25", "foundry-compilers-core", "futures-util", "path-slash", @@ -3954,8 +4076,8 @@ version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2eaf3cad3dd7bd9eae02736e98f55aaf00ee31fbc0a367613436c2fb01c43914" dependencies = [ - "alloy-json-abi", - "alloy-primitives", + "alloy-json-abi 0.8.25", + "alloy-primitives 0.8.25", "foundry-compilers-artifacts-solc", "foundry-compilers-core", "path-slash", @@ -3969,7 +4091,7 @@ version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ac5a1aef4083544309765a1a10c310dffde8c9b8bcfda79b7c2bcfde32f3be3" dependencies = [ - "alloy-primitives", + "alloy-primitives 0.8.25", "cfg-if", "dunce", "fs_extra", @@ -3991,7 +4113,8 @@ name = "foundry-config" version = "1.1.0" dependencies = [ "alloy-chains", - "alloy-primitives", + "alloy-primitives 0.8.25", + "clap", "dirs", "dunce", "eyre", @@ -4012,6 +4135,7 @@ dependencies = [ "serde", "serde_json", "similar-asserts", + "solar-interface", "solar-parse", "soldeer-core", "tempfile", @@ -4027,7 +4151,7 @@ dependencies = [ name = "foundry-debugger" version = "1.1.0" dependencies = [ - "alloy-primitives", + "alloy-primitives 0.8.25", "crossterm", "eyre", "foundry-common", @@ -4046,8 +4170,8 @@ name = "foundry-evm" version = "1.1.0" dependencies = [ "alloy-dyn-abi", - "alloy-json-abi", - "alloy-primitives", + "alloy-json-abi 0.8.25", + "alloy-primitives 0.8.25", "alloy-sol-types", "eyre", "foundry-cheatcodes", @@ -4072,7 +4196,7 @@ dependencies = [ name = "foundry-evm-abi" version = "1.1.0" dependencies = [ - "alloy-primitives", + "alloy-primitives 0.8.25", "alloy-sol-types", "derive_more 2.0.1", "foundry-common-fmt", @@ -4087,9 +4211,9 @@ dependencies = [ "alloy-consensus", "alloy-dyn-abi", "alloy-genesis", - "alloy-json-abi", + "alloy-json-abi 0.8.25", "alloy-network", - "alloy-primitives", + "alloy-primitives 0.8.25", "alloy-provider", "alloy-rpc-types", "alloy-sol-types", @@ -4118,7 +4242,7 @@ dependencies = [ name = "foundry-evm-coverage" version = "1.1.0" dependencies = [ - "alloy-primitives", + "alloy-primitives 0.8.25", "eyre", "foundry-common", "foundry-compilers", @@ -4134,8 +4258,8 @@ name = "foundry-evm-fuzz" version = "1.1.0" dependencies = [ "alloy-dyn-abi", - "alloy-json-abi", - "alloy-primitives", + "alloy-json-abi 0.8.25", + "alloy-primitives 0.8.25", "eyre", "foundry-common", "foundry-compilers", @@ -4158,8 +4282,8 @@ name = "foundry-evm-traces" version = "1.1.0" dependencies = [ "alloy-dyn-abi", - "alloy-json-abi", - "alloy-primitives", + "alloy-json-abi 0.8.25", + "alloy-primitives 0.8.25", "alloy-sol-types", "eyre", "foundry-block-explorers", @@ -4188,7 +4312,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba7beb856e73f59015823eb221a98b7c22b58bc4e7066c9c86774ebe74e61dd6" dependencies = [ "alloy-consensus", - "alloy-primitives", + "alloy-primitives 0.8.25", "alloy-provider", "alloy-rpc-types", "eyre", @@ -4207,7 +4331,7 @@ dependencies = [ name = "foundry-linking" version = "1.1.0" dependencies = [ - "alloy-primitives", + "alloy-primitives 0.8.25", "foundry-compilers", "semver 1.0.26", "thiserror 2.0.12", @@ -4227,7 +4351,7 @@ dependencies = [ name = "foundry-test-utils" version = "1.1.0" dependencies = [ - "alloy-primitives", + "alloy-primitives 0.8.25", "alloy-provider", "eyre", "fd-lock", @@ -4245,6 +4369,7 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", + "ui_test", ] [[package]] @@ -4254,7 +4379,7 @@ dependencies = [ "alloy-consensus", "alloy-dyn-abi", "alloy-network", - "alloy-primitives", + "alloy-primitives 0.8.25", "alloy-signer", "alloy-signer-aws", "alloy-signer-gcp", @@ -5590,6 +5715,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" +[[package]] +name = "levenshtein" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db13adb97ab515a3691f56e4dbab09283d0b86cb45abd991d8634a9d6f501760" + [[package]] name = "libc" version = "0.2.172" @@ -6242,7 +6373,7 @@ checksum = "889facbf449b2d9c8de591cd467a6c7217936f3c1c07a281759c01c49d08d66d" dependencies = [ "alloy-consensus", "alloy-eips", - "alloy-primitives", + "alloy-primitives 0.8.25", "alloy-rlp", "alloy-serde", "derive_more 2.0.1", @@ -6259,7 +6390,7 @@ dependencies = [ "alloy-consensus", "alloy-eips", "alloy-network-primitives", - "alloy-primitives", + "alloy-primitives 0.8.25", "alloy-rpc-types-eth", "alloy-serde", "derive_more 2.0.1", @@ -6322,6 +6453,15 @@ dependencies = [ "sha2", ] +[[package]] +name = "pad" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ad9b889f1b12e0b9ee24db044b5129150d5eada288edc800f789928dc8c0e3" +dependencies = [ + "unicode-width 0.1.14", +] + [[package]] name = "parity-scale-codec" version = "3.7.4" @@ -6705,6 +6845,16 @@ dependencies = [ "termtree", ] +[[package]] +name = "prettydiff" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abec3fb083c10660b3854367697da94c674e9e82aa7511014dc958beeb7215e9" +dependencies = [ + "owo-colors", + "pad", +] + [[package]] name = "prettyplease" version = "0.2.32" @@ -7068,6 +7218,7 @@ checksum = "9fbfd9d094a40bf3ae768db9361049ace4c0e04a4fd6b359518bd7b73a73dd97" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.3", + "serde", ] [[package]] @@ -7106,6 +7257,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" dependencies = [ "getrandom 0.3.2", + "serde", ] [[package]] @@ -7320,7 +7472,7 @@ version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6a43423d81f4bef634469bfb2d9ebe36a9ea9167f20ab3a7d1ff1e05fa63099" dependencies = [ - "alloy-primitives", + "alloy-primitives 0.8.25", "alloy-rpc-types-eth", "alloy-rpc-types-trace", "alloy-sol-types", @@ -7370,7 +7522,7 @@ checksum = "f0f987564210317706def498421dfba2ae1af64a8edce82c6102758b48133fcb" dependencies = [ "alloy-eip2930", "alloy-eip7702", - "alloy-primitives", + "alloy-primitives 0.8.25", "auto_impl", "bitflags 2.9.0", "bitvec", @@ -7544,6 +7696,18 @@ dependencies = [ "semver 1.0.26", ] +[[package]] +name = "rustfix" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82fa69b198d894d84e23afde8e9ab2af4400b2cba20d6bf2b428a8b01c222c5a" +dependencies = [ + "serde", + "serde_json", + "thiserror 1.0.69", + "tracing", +] + [[package]] name = "rustix" version = "0.38.44" @@ -7701,15 +7865,6 @@ dependencies = [ "regex", ] -[[package]] -name = "scc" -version = "2.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22b2d775fb28f245817589471dd49c5edf64237f4a19d10ce9a92ff4651a27f4" -dependencies = [ - "sdd", -] - [[package]] name = "schannel" version = "0.1.27" @@ -7767,12 +7922,6 @@ dependencies = [ "sha2", ] -[[package]] -name = "sdd" -version = "3.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "584e070911c7017da6cb2eb0788d09f43d789029b5877d3e5ecc8acf86ceee21" - [[package]] name = "sec1" version = "0.7.3" @@ -8241,12 +8390,10 @@ dependencies = [ [[package]] name = "solar-ast" version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0a583a12e73099d1f54bfe7c8a30d7af5ff3591c61ee51cce91045ee5496d86" +source = "git+https://github.com/paradigmxyz/solar?branch=main#65186e7a3b2b6e1412d6ca8d544f9622311a1e81" dependencies = [ - "alloy-primitives", + "alloy-primitives 1.1.0", "bumpalo", - "derive_more 2.0.1", "either", "num-bigint", "num-rational", @@ -8261,8 +8408,7 @@ dependencies = [ [[package]] name = "solar-config" version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12642e7e8490d6855a345b5b9d5e55630bd30f54450a909e28f1385b448baada" +source = "git+https://github.com/paradigmxyz/solar?branch=main#65186e7a3b2b6e1412d6ca8d544f9622311a1e81" dependencies = [ "strum 0.27.1", ] @@ -8270,8 +8416,7 @@ dependencies = [ [[package]] name = "solar-data-structures" version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dae8902cc28af53e2ba97c450aff7c59d112a433f9ef98fae808e02e25e6dee6" +source = "git+https://github.com/paradigmxyz/solar?branch=main#65186e7a3b2b6e1412d6ca8d544f9622311a1e81" dependencies = [ "bumpalo", "index_vec", @@ -8285,8 +8430,7 @@ dependencies = [ [[package]] name = "solar-interface" version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ded5ec7a5cee351c7a428842d273470180cab259c46f52d502ec3ab5484d0c3a" +source = "git+https://github.com/paradigmxyz/solar?branch=main#65186e7a3b2b6e1412d6ca8d544f9622311a1e81" dependencies = [ "annotate-snippets", "anstream", @@ -8301,8 +8445,9 @@ dependencies = [ "match_cfg", "normalize-path", "rayon", - "scc", "scoped-tls", + "serde", + "serde_json", "solar-config", "solar-data-structures", "solar-macros", @@ -8314,8 +8459,7 @@ dependencies = [ [[package]] name = "solar-macros" version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca2c9ff6e00eeeff12eac9d589f1f20413d3b71b9c0c292d1eefbd34787e0836" +source = "git+https://github.com/paradigmxyz/solar?branch=main#65186e7a3b2b6e1412d6ca8d544f9622311a1e81" dependencies = [ "proc-macro2", "quote", @@ -8325,10 +8469,9 @@ dependencies = [ [[package]] name = "solar-parse" version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e1bc1d0253b0f7f2c7cd25ed7bc5d5e8cac43e717d002398250e0e66e43278b" +source = "git+https://github.com/paradigmxyz/solar?branch=main#65186e7a3b2b6e1412d6ca8d544f9622311a1e81" dependencies = [ - "alloy-primitives", + "alloy-primitives 1.1.0", "bitflags 2.9.0", "bumpalo", "itertools 0.14.0", @@ -8346,11 +8489,10 @@ dependencies = [ [[package]] name = "solar-sema" version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ded4b26fb85a0ae2f3277377236af0884c82f38965a2c51046a53016c8b5f332" +source = "git+https://github.com/paradigmxyz/solar?branch=main#65186e7a3b2b6e1412d6ca8d544f9622311a1e81" dependencies = [ - "alloy-json-abi", - "alloy-primitives", + "alloy-json-abi 1.1.0", + "alloy-primitives 1.1.0", "bitflags 2.9.0", "bumpalo", "derive_more 2.0.1", @@ -8358,7 +8500,6 @@ dependencies = [ "once_map", "paste", "rayon", - "scc", "serde", "serde_json", "solar-ast", @@ -8422,6 +8563,16 @@ dependencies = [ "zip-extract", ] +[[package]] +name = "spanned" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86af297923fbcfd107c20a189a6e9c872160df71a7190ae4a7a6c5dce4b2feb6" +dependencies = [ + "bstr", + "color-eyre", +] + [[package]] name = "spin" version = "0.9.8" @@ -9425,6 +9576,32 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "ui_test" +version = "0.29.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1211b1111c752c73b33073d2958072be08825fd97c9ab4d83444da361a06634b" +dependencies = [ + "annotate-snippets", + "anyhow", + "bstr", + "cargo-platform", + "cargo_metadata", + "color-eyre", + "colored", + "comma", + "crossbeam-channel", + "indicatif", + "levenshtein", + "prettydiff", + "regex", + "rustc_version 0.4.1", + "rustfix", + "serde", + "serde_json", + "spanned", +] + [[package]] name = "uint" version = "0.9.5" diff --git a/Cargo.toml b/Cargo.toml index e76ac670d7cf7..282a954a2e98e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ members = [ "crates/script-sequence/", "crates/macros/", "crates/test-utils/", + "crates/lint/", ] resolver = "2" @@ -165,6 +166,7 @@ forge = { path = "crates/forge" } forge-doc = { path = "crates/doc" } forge-fmt = { path = "crates/fmt" } +forge-lint = { path = "crates/lint" } forge-verify = { path = "crates/verify" } forge-script = { path = "crates/script" } forge-sol-macro-gen = { path = "crates/sol-macro-gen" } @@ -192,7 +194,9 @@ foundry-block-explorers = { version = "0.13.3", default-features = false } foundry-compilers = { version = "0.14.0", default-features = false } foundry-fork-db = "0.12" solang-parser = "=0.3.3" +solar-ast = { version = "=0.1.2", default-features = false } solar-parse = { version = "=0.1.2", default-features = false } +solar-interface = { version = "=0.1.2", default-features = false } solar-sema = { version = "=0.1.2", default-features = false } ## revm @@ -317,6 +321,7 @@ vergen = { version = "8", default-features = false } yansi = { version = "1.0", features = ["detect-tty", "detect-env"] } path-slash = "0.2" jiff = "0.2" +heck = "0.5" # Use unicode-rs which has a smaller binary size than the default ICU4X as the IDNA backend, used # by the `url` crate. @@ -359,3 +364,10 @@ idna_adapter = "=1.1.0" # alloy-transport-http = { git = "https://github.com/alloy-rs/alloy", rev = "7fab7ee" } # alloy-transport-ipc = { git = "https://github.com/alloy-rs/alloy", rev = "7fab7ee" } # alloy-transport-ws = { git = "https://github.com/alloy-rs/alloy", rev = "7fab7ee" } + +# TODO: comment out after 0.1.3 release +# solar +solar-ast = { git = "https://github.com/paradigmxyz/solar", branch = "main", package = "solar-ast" } +solar-parse = { git = "https://github.com/paradigmxyz/solar", branch = "main", package = "solar-parse" } +solar-interface = { git = "https://github.com/paradigmxyz/solar", branch = "main", package = "solar-interface" } +solar-sema = { git = "https://github.com/paradigmxyz/solar", branch = "main", package = "solar-sema" } diff --git a/crates/common/src/preprocessor/deps.rs b/crates/common/src/preprocessor/deps.rs index e15e21798e9af..22feb0d16a468 100644 --- a/crates/common/src/preprocessor/deps.rs +++ b/crates/common/src/preprocessor/deps.rs @@ -194,7 +194,7 @@ impl<'hir> Visit<'hir> for BytecodeDependencyCollector<'hir> { // the offset will be used to replace `{value: 333} ( ` with `(` let call_args_offset = if named_args.is_some() && !call_args.is_empty() { - (call_args.span().lo() - ty_new.span.hi()).to_usize() + (call_args.span.lo() - ty_new.span.hi()).to_usize() } else { 0 }; @@ -323,9 +323,6 @@ pub(crate) fn remove_bytecode_dependencies( "_args: encodeArgs{id}(DeployHelper{id}.FoundryPpConstructorArgs", id = dep.referenced_contract.get() )); - if *call_args_offset > 0 { - update.push('('); - } updates.insert((dep.loc.start, dep.loc.end + call_args_offset, update)); updates.insert(( dep.loc.end + args_length, diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml index 816f06054734f..1b8a787a9cd36 100644 --- a/crates/config/Cargo.toml +++ b/crates/config/Cargo.toml @@ -22,6 +22,7 @@ alloy-primitives = { workspace = true, features = ["serde"] } revm-primitives.workspace = true solar-parse.workspace = true +solar-interface.workspace = true dirs.workspace = true dunce.workspace = true @@ -29,7 +30,7 @@ eyre.workspace = true figment = { workspace = true, features = ["toml", "env"] } glob = "0.3" globset = "0.4" -heck = "0.5" +heck.workspace = true itertools.workspace = true mesc.workspace = true number_prefix = "0.4" @@ -45,6 +46,7 @@ toml_edit = "0.22" tracing.workspace = true walkdir.workspace = true yansi.workspace = true +clap = { version = "4", features = ["derive"] } [target.'cfg(target_os = "windows")'.dependencies] path-slash = "0.2" diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 2abb5a04cb233..29848255db92a 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -74,6 +74,9 @@ use cache::{Cache, ChainCache}; pub mod fmt; pub use fmt::FormatterConfig; +pub mod lint; +pub use lint::{LinterConfig, Severity as LintSeverity}; + pub mod fs_permissions; pub use fs_permissions::FsPermissions; use fs_permissions::PathPermission; @@ -449,6 +452,8 @@ pub struct Config { pub build_info_path: Option, /// Configuration for `forge fmt` pub fmt: FormatterConfig, + /// Configuration for `forge lint` + pub lint: LinterConfig, /// Configuration for `forge doc` pub doc: DocConfig, /// Configuration for `forge bind-json` @@ -565,6 +570,7 @@ impl Config { "rpc_endpoints", "etherscan", "fmt", + "lint", "doc", "fuzz", "invariant", @@ -2420,6 +2426,7 @@ impl Default for Config { build_info: false, build_info_path: None, fmt: Default::default(), + lint: Default::default(), doc: Default::default(), bind_json: Default::default(), labels: Default::default(), @@ -4518,6 +4525,31 @@ mod tests { }); } + #[test] + fn test_lint_config() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "foundry.toml", + r" + [lint] + severity = ['high', 'medium'] + exclude_lints = ['incorrect-shift'] + ", + )?; + let loaded = Config::load().unwrap().sanitized(); + assert_eq!( + loaded.lint, + LinterConfig { + severity: vec![LintSeverity::High, LintSeverity::Med], + exclude_lints: vec!["incorrect-shift".into()], + ..Default::default() + } + ); + + Ok(()) + }); + } + #[test] fn test_invariant_config() { figment::Jail::expect_with(|jail| { diff --git a/crates/config/src/lint.rs b/crates/config/src/lint.rs new file mode 100644 index 0000000000000..10d86c96fb321 --- /dev/null +++ b/crates/config/src/lint.rs @@ -0,0 +1,93 @@ +//! Configuration specific to the `forge lint` command and the `forge_lint` package + +use clap::ValueEnum; +use core::fmt; +use serde::{Deserialize, Deserializer, Serialize}; +use solar_interface::diagnostics::Level; +use std::str::FromStr; +use yansi::Paint; + +/// Contains the config and rule set +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct LinterConfig { + /// Specifies which lints to run based on severity. + /// + /// If uninformed, all severities are checked. + pub severity: Vec, + + /// Deny specific lints based on their ID (e.g. "mixed-case-function"). + pub exclude_lints: Vec, + + /// Globs to ignore + pub ignore: Vec, +} + +/// Severity of a lint +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum, Serialize)] +pub enum Severity { + High, + Med, + Low, + Info, + Gas, +} + +impl Severity { + pub fn color(&self, message: &str) -> String { + match self { + Self::High => Paint::red(message).bold().to_string(), + Self::Med => Paint::rgb(message, 255, 135, 61).bold().to_string(), + Self::Low => Paint::yellow(message).bold().to_string(), + Self::Info => Paint::cyan(message).bold().to_string(), + Self::Gas => Paint::green(message).bold().to_string(), + } + } +} + +impl From for Level { + fn from(severity: Severity) -> Self { + match severity { + Severity::High | Severity::Med | Severity::Low => Self::Warning, + Severity::Info | Severity::Gas => Self::Note, + } + } +} + +impl fmt::Display for Severity { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let colored = match self { + Self::High => self.color("High"), + Self::Med => self.color("Med"), + Self::Low => self.color("Low"), + Self::Info => self.color("Info"), + Self::Gas => self.color("Gas"), + }; + write!(f, "{colored}") + } +} + +// Custom deserialization to make `Severity` parsing case-insensitive +impl<'de> Deserialize<'de> for Severity { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + FromStr::from_str(&s).map_err(serde::de::Error::custom) + } +} + +impl FromStr for Severity { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "high" => Ok(Self::High), + "med" | "medium" => Ok(Self::Med), + "low" => Ok(Self::Low), + "info" => Ok(Self::Info), + "gas" => Ok(Self::Gas), + _ => Err(format!("unknown variant: found `{s}`, expected `one of `High`, `Med`, `Low`, `Info`, `Gas``")), + } + } +} diff --git a/crates/forge/Cargo.toml b/crates/forge/Cargo.toml index 4f8a20e86c9ad..e9140d0f4c679 100644 --- a/crates/forge/Cargo.toml +++ b/crates/forge/Cargo.toml @@ -17,6 +17,11 @@ workspace = true name = "forge" path = "bin/main.rs" +[[test]] +name = "ui" +path = "tests/ui.rs" +harness = false + [dependencies] # lib foundry-block-explorers = { workspace = true, features = ["foundry-compilers"] } @@ -40,6 +45,7 @@ chrono.workspace = true # bin forge-doc.workspace = true forge-fmt.workspace = true +forge-lint.workspace = true forge-verify.workspace = true forge-script.workspace = true forge-sol-macro-gen.workspace = true @@ -73,6 +79,7 @@ serde_json.workspace = true similar = { version = "2", features = ["inline"] } solang-parser.workspace = true solar-parse.workspace = true +solar-interface.workspace = true strum = { workspace = true, features = ["derive"] } thiserror.workspace = true tokio = { workspace = true, features = ["time"] } diff --git a/crates/forge/src/args.rs b/crates/forge/src/args.rs index 92922155c5775..2d411d7c30bc8 100644 --- a/crates/forge/src/args.rs +++ b/crates/forge/src/args.rs @@ -151,5 +151,6 @@ pub fn run_command(args: Forge) -> Result<()> { ForgeSubcommand::Soldeer(cmd) => utils::block_on(cmd.run()), ForgeSubcommand::Eip712(cmd) => cmd.run(), ForgeSubcommand::BindJson(cmd) => cmd.run(), + ForgeSubcommand::Lint(cmd) => cmd.run(), } } diff --git a/crates/forge/src/cmd/lint.rs b/crates/forge/src/cmd/lint.rs new file mode 100644 index 0000000000000..f81c252c40fdb --- /dev/null +++ b/crates/forge/src/cmd/lint.rs @@ -0,0 +1,119 @@ +use clap::{Parser, ValueHint}; +use eyre::{eyre, Result}; +use forge_lint::{ + linter::Linter, + sol::{SolLint, SolLintError, SolidityLinter}, +}; +use foundry_cli::utils::{FoundryPathExt, LoadConfig}; +use foundry_compilers::{solc::SolcLanguage, utils::SOLC_EXTENSIONS}; +use foundry_config::{filter::expand_globs, impl_figment_convert_basic, lint::Severity}; +use std::path::PathBuf; + +/// CLI arguments for `forge lint`. +#[derive(Clone, Debug, Parser)] +pub struct LintArgs { + /// Path to the file to be checked. Overrides the `ignore` project config. + #[arg(value_hint = ValueHint::FilePath, value_name = "PATH", num_args(1..))] + paths: Vec, + + /// The project's root path. + /// + /// By default root of the Git repository, if in one, + /// or the current working directory. + #[arg(long, value_hint = ValueHint::DirPath, value_name = "PATH")] + root: Option, + + /// Specifies which lints to run based on severity. Overrides the `severity` project config. + /// + /// Supported values: `high`, `med`, `low`, `info`, `gas`. + #[arg(long, value_name = "SEVERITY", num_args(1..))] + severity: Option>, + + /// Specifies which lints to run based on their ID (e.g., "incorrect-shift"). Overrides the + /// `exclude_lints` project config. + #[arg(long = "only-lint", value_name = "LINT_ID", num_args(1..))] + lint: Option>, + + /// Activates the linter's JSON formatter (rustc-compatible). + #[arg(long)] + json: bool, +} + +impl_figment_convert_basic!(LintArgs); + +impl LintArgs { + pub fn run(self) -> Result<()> { + let config = self.load_config()?; + let project = config.project()?; + + // Expand ignore globs and canonicalize from the get go + let ignored = expand_globs(&config.root, config.lint.ignore.iter())? + .iter() + .flat_map(foundry_common::fs::canonicalize_path) + .collect::>(); + + let cwd = std::env::current_dir()?; + let input = match &self.paths[..] { + [] => { + // Retrieve the project paths, and filter out the ignored ones. + let project_paths = config + .project_paths::() + .input_files_iter() + .filter(|p| !(ignored.contains(p) || ignored.contains(&cwd.join(p)))) + .collect(); + project_paths + } + paths => { + // Override default excluded paths and only lint the input files. + let mut inputs = Vec::with_capacity(paths.len()); + for path in paths { + if path.is_dir() { + inputs + .extend(foundry_compilers::utils::source_files(path, SOLC_EXTENSIONS)); + } else if path.is_sol() { + inputs.push(path.to_path_buf()); + } else { + warn!("Cannot process path {}", path.display()); + } + } + inputs + } + }; + + if input.is_empty() { + sh_println!("Nothing to lint")?; + return Ok(()); + } + + let parse_lints = |lints: &[String]| -> Result, SolLintError> { + lints.iter().map(|s| SolLint::try_from(s.as_str())).collect() + }; + + // Override default lint config with user-defined lints + let (include, exclude) = match &self.lint { + Some(cli_lints) => (Some(parse_lints(cli_lints)?), None), + None => (None, Some(parse_lints(&config.lint.exclude_lints)?)), + }; + + // Override default severity config with user-defined severity + let severity = match self.severity { + Some(target) => target, + None => config.lint.severity, + }; + + if project.compiler.solc.is_none() { + return Err(eyre!("Linting not supported for this language")); + } + + let linter = SolidityLinter::new() + .with_json_emitter(self.json) + .with_description(true) + .with_lints(include) + .without_lints(exclude) + .with_severity(if severity.is_empty() { None } else { Some(severity) }); + + linter.lint(&input); + + Ok(()) + } +} diff --git a/crates/forge/src/cmd/mod.rs b/crates/forge/src/cmd/mod.rs index d633564f69cf4..0a0945bab99e9 100644 --- a/crates/forge/src/cmd/mod.rs +++ b/crates/forge/src/cmd/mod.rs @@ -23,6 +23,7 @@ pub mod generate; pub mod init; pub mod inspect; pub mod install; +pub mod lint; pub mod remappings; pub mod remove; pub mod selectors; diff --git a/crates/forge/src/opts.rs b/crates/forge/src/opts.rs index 0867589a39c62..a50de6c7be59e 100644 --- a/crates/forge/src/opts.rs +++ b/crates/forge/src/opts.rs @@ -1,7 +1,7 @@ use crate::cmd::{ bind::BindArgs, bind_json, build::BuildArgs, cache::CacheArgs, clone::CloneArgs, compiler::CompilerArgs, config, coverage, create::CreateArgs, doc::DocArgs, eip712, flatten, - fmt::FmtArgs, geiger, generate, init::InitArgs, inspect, install::InstallArgs, + fmt::FmtArgs, geiger, generate, init::InitArgs, inspect, install::InstallArgs, lint::LintArgs, remappings::RemappingArgs, remove::RemoveArgs, selectors::SelectorsSubcommands, snapshot, soldeer, test, tree, update, }; @@ -132,6 +132,10 @@ pub enum ForgeSubcommand { /// Format Solidity source files. Fmt(FmtArgs), + /// Lint Solidity source files + #[command(visible_alias = "l")] + Lint(LintArgs), + /// Get specialized information about a smart contract. #[command(visible_alias = "in")] Inspect(inspect::InspectArgs), diff --git a/crates/forge/tests/cli/config.rs b/crates/forge/tests/cli/config.rs index 41db96842d0ef..35916b9554f8a 100644 --- a/crates/forge/tests/cli/config.rs +++ b/crates/forge/tests/cli/config.rs @@ -148,6 +148,7 @@ forgetest!(can_extract_config_values, |prj, cmd| { build_info: false, build_info_path: None, fmt: Default::default(), + lint: Default::default(), doc: Default::default(), bind_json: Default::default(), fs_permissions: Default::default(), @@ -1069,6 +1070,11 @@ ignore = [] contract_new_lines = false sort_imports = false +[lint] +severity = [] +exclude_lints = [] +ignore = [] + [doc] out = "docs" title = "" @@ -1269,6 +1275,11 @@ exclude = [] "contract_new_lines": false, "sort_imports": false }, + "lint": { + "severity": [], + "exclude_lints": [], + "ignore": [] + }, "doc": { "out": "docs", "title": "", diff --git a/crates/forge/tests/cli/lint.rs b/crates/forge/tests/cli/lint.rs new file mode 100644 index 0000000000000..7f125e9877867 --- /dev/null +++ b/crates/forge/tests/cli/lint.rs @@ -0,0 +1,167 @@ +use foundry_config::{LintSeverity, LinterConfig}; + +const CONTRACT: &str = r#" +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +struct _PascalCaseInfo { uint256 a; } +uint256 constant screaming_snake_case_info = 0; + +contract ContractWithLints { + uint256 VARIABLE_MIXED_CASE_INFO; + + function incorrectShiftHigh() public { + uint256 localValue = 50; + result = 8 >> localValue; + } + function divideBeforeMultiplyMedium() public { + (1 / 2) * 3; + } + function unoptimizedHashGas(uint256 a, uint256 b) public view { + keccak256(abi.encodePacked(a, b)); + } + function FUNCTION_MIXED_CASE_INFO() public {} +} + "#; + +const OTHER_CONTRACT: &str = r#" + // SPDX-License-Identifier: MIT + pragma solidity ^0.8.0; + + contract ContractWithLints { + uint256 VARIABLE_MIXED_CASE_INFO; + } + "#; + +forgetest!(can_use_config, |prj, cmd| { + prj.wipe_contracts(); + prj.add_source("ContractWithLints", CONTRACT).unwrap(); + prj.add_source("OtherContractWithLints", OTHER_CONTRACT).unwrap(); + + // Check config for `severity` and `exclude` + prj.update_config(|config| { + config.lint = LinterConfig { + severity: vec![LintSeverity::High, LintSeverity::Med], + exclude_lints: vec!["incorrect-shift".into()], + ..Default::default() + }; + }); + cmd.arg("lint").assert_success().stderr_eq(str![[r#" +warning[divide-before-multiply]: multiplication should occur before division to avoid loss of precision + [FILE]:16:9 + | +16 | (1 / 2) * 3; + | ----------- + | + + +"#]]); +}); + +forgetest!(can_use_config_ignore, |prj, cmd| { + prj.wipe_contracts(); + prj.add_source("ContractWithLints", CONTRACT).unwrap(); + prj.add_source("OtherContract", OTHER_CONTRACT).unwrap(); + + // Check config for `ignore` + prj.update_config(|config| { + config.lint = + LinterConfig { ignore: vec!["src/ContractWithLints.sol".into()], ..Default::default() }; + }); + cmd.arg("lint").assert_success().stderr_eq(str![[r#" +note[mixed-case-variable]: mutable variables should use mixedCase + [FILE]:6:9 + | +6 | uint256 VARIABLE_MIXED_CASE_INFO; + | --------------------------------- + | + + +"#]]); + + // Check config again, ignoring all files + prj.update_config(|config| { + config.lint = LinterConfig { + ignore: vec!["src/ContractWithLints.sol".into(), "src/OtherContract.sol".into()], + ..Default::default() + }; + }); + cmd.arg("lint").assert_success().stderr_eq(str![[""]]); +}); + +forgetest!(can_override_config_severity, |prj, cmd| { + prj.wipe_contracts(); + prj.add_source("ContractWithLints", CONTRACT).unwrap(); + prj.add_source("OtherContractWithLints", OTHER_CONTRACT).unwrap(); + + // Override severity + prj.update_config(|config| { + config.lint = LinterConfig { + severity: vec![LintSeverity::High, LintSeverity::Med], + ignore: vec!["src/ContractWithLints.sol".into()], + ..Default::default() + }; + }); + cmd.arg("lint").args(["--severity", "info"]).assert_success().stderr_eq(str![[r#" +note[mixed-case-variable]: mutable variables should use mixedCase + [FILE]:6:9 + | +6 | uint256 VARIABLE_MIXED_CASE_INFO; + | --------------------------------- + | + + +"#]]); +}); + +forgetest!(can_override_config_path, |prj, cmd| { + prj.wipe_contracts(); + prj.add_source("ContractWithLints", CONTRACT).unwrap(); + prj.add_source("OtherContractWithLints", OTHER_CONTRACT).unwrap(); + + // Override excluded files + prj.update_config(|config| { + config.lint = LinterConfig { + severity: vec![LintSeverity::High, LintSeverity::Med], + exclude_lints: vec!["incorrect-shift".into()], + ignore: vec!["src/ContractWithLints.sol".into()], + }; + }); + cmd.arg("lint").arg("src/ContractWithLints.sol").assert_success().stderr_eq(str![[r#" +warning[divide-before-multiply]: multiplication should occur before division to avoid loss of precision + [FILE]:16:9 + | +16 | (1 / 2) * 3; + | ----------- + | + + +"#]]); +}); + +forgetest!(can_override_config_lint, |prj, cmd| { + prj.wipe_contracts(); + prj.add_source("ContractWithLints", CONTRACT).unwrap(); + prj.add_source("OtherContractWithLints", OTHER_CONTRACT).unwrap(); + + // Override excluded lints + prj.update_config(|config| { + config.lint = LinterConfig { + severity: vec![LintSeverity::High, LintSeverity::Med], + exclude_lints: vec!["incorrect-shift".into()], + ..Default::default() + }; + }); + cmd.arg("lint").args(["--only-lint", "incorrect-shift"]).assert_success().stderr_eq(str![[ + r#" +warning[incorrect-shift]: the order of args in a shift operation is incorrect + [FILE]:13:18 + | +13 | result = 8 >> localValue; + | --------------- + | + + +"# + ]]); +}); diff --git a/crates/forge/tests/cli/main.rs b/crates/forge/tests/cli/main.rs index d48eae912c050..7cbdfba2abf64 100644 --- a/crates/forge/tests/cli/main.rs +++ b/crates/forge/tests/cli/main.rs @@ -20,6 +20,7 @@ mod eof; mod failure_assertions; mod geiger; mod inline_config; +mod lint; mod multi_script; mod script; mod soldeer; diff --git a/crates/forge/tests/ui.rs b/crates/forge/tests/ui.rs new file mode 100644 index 0000000000000..9b37b7b1f904d --- /dev/null +++ b/crates/forge/tests/ui.rs @@ -0,0 +1,13 @@ +use foundry_test_utils::ui_runner; +use std::{env, path::Path}; + +const FORGE_CMD: &str = env!("CARGO_BIN_EXE_forge"); +const FORGE_DIR: &str = env!("CARGO_MANIFEST_DIR"); + +fn main() -> impl std::process::Termination { + let forge_cmd = Path::new(FORGE_CMD); + let forge_dir = Path::new(FORGE_DIR); + let lint_testdata = forge_dir.parent().unwrap().join("lint").join("testdata"); + + ui_runner::run_tests("lint", forge_cmd, &lint_testdata) +} diff --git a/crates/lint/Cargo.toml b/crates/lint/Cargo.toml new file mode 100644 index 0000000000000..a621caa713d07 --- /dev/null +++ b/crates/lint/Cargo.toml @@ -0,0 +1,27 @@ + +[package] +name = "forge-lint" + +version.workspace = true +edition.workspace = true +rust-version.workspace = true +authors.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +# lib +foundry-compilers.workspace = true +foundry-config.workspace = true + +solar-parse.workspace = true +solar-ast.workspace = true +solar-interface = { workspace = true, features = ["json"] } + +heck.workspace = true +rayon.workspace = true +thiserror.workspace = true diff --git a/crates/lint/README.md b/crates/lint/README.md new file mode 100644 index 0000000000000..aae8ae0e2ab5c --- /dev/null +++ b/crates/lint/README.md @@ -0,0 +1,67 @@ +# Linter (`lint`) + +Solidity linter for identifying potential errors, vulnerabilities, gas optimizations, and style guide violations. +It helps enforce best practices and improve code quality within Foundry projects. + +## Supported Lints + +`forge-lint` includes rules across several categories: + +* **High Severity:** + * `incorrect-shift`: Warns against shift operations where operands might be in the wrong order. +* **Medium Severity:** + * `divide-before-multiply`: Warns against performing division before multiplication in the same expression, which can cause precision loss. +* **Informational / Style Guide:** + * `pascal-case-struct`: Flags for struct names not adhering to `PascalCase`. + * `mixed-case-function`: Flags for function names not adhering to `mixedCase`. + * `mixed-case-variable`: Flags for mutable variable names not adhering to `mixedCase`. + * `screaming-snake-case-const`: Flags for `constant` variable names not adhering to `SCREAMING_SNAKE_CASE`. + * `screaming-snake-case-immutable`: Flags for `immutable` variable names not adhering to `SCREAMING_SNAKE_CASE`. +* **Gas Optimizations:** + * `asm-keccak256`: Recommends using inline assembly for `keccak256` for potential gas savings. + +## Configuration + +The behavior of the `SolidityLinter` can be customized with the following options: + +| Option | Default | Description | +|---------------------|---------|------------------------------------------------------------------------------------------------------------| +| `with_severity` | `None` | Filters active lints by their severity (`High`, `Med`, `Low`, `Info`, `Gas`). `None` means all severities. | +| `with_lints` | `None` | Specifies a list of `SolLint` instances to include. Overrides severity filter if a lint matches. | +| `without_lints` | `None` | Specifies a list of `SolLint` instances to exclude, even if they match other criteria. | +| `with_description` | `true` | Whether to include the lint's description in the diagnostic output. | +| `with_json_emitter` | `false` | If `true`, diagnostics are output in rustc-compatible JSON format; otherwise, human-readable text. | + +## Contributing + +Check out the [foundry contribution guide](https://github.com/foundry-rs/foundry/blob/master/CONTRIBUTING.md). + +Guidelines for contributing to `forge lint`: + +### Opening an issue + +1. Create a short concise title describing an issue. + - Bad Title Examples + ```text + Forge lint does not work + Forge lint breaks + Forge lint unexpected behavior + ``` + - Good Title Examples + ```text + Forge lint does not flag incorrect shift operations + ``` +2. Fill in the issue template fields that include foundry version, platform & component info. +3. Provide the code snippets showing the current & expected behaviors. +4. If it's a feature request, specify why this feature is needed. +5. Besides the default label (`T-Bug` for bugs or `T-feature` for features), add `C-forge` and `Cmd-forge-fmt` labels. + +### Fixing A Bug + +1. Specify an issue that is being addressed in the PR description. +2. Add a note on the solution in the PR description. +3. Add a test case to `lint/testdata` that specifically demonstrates the bug and is fixed by your changes. Ensure all tests pass. + +### Developing a New Lint Rule + +Check the [dev docs](../../docs/dev/lintrules.md) for a full implementation guide. diff --git a/crates/lint/src/lib.rs b/crates/lint/src/lib.rs new file mode 100644 index 0000000000000..5dd1d9d10b209 --- /dev/null +++ b/crates/lint/src/lib.rs @@ -0,0 +1,6 @@ +#![doc = include_str!("../README.md")] +#![cfg_attr(not(test), warn(unused_crate_dependencies))] +#![cfg_attr(docsrs, feature(doc_cfg, doc_auto_cfg))] + +pub mod linter; +pub mod sol; diff --git a/crates/lint/src/linter.rs b/crates/lint/src/linter.rs new file mode 100644 index 0000000000000..6c188ff86497f --- /dev/null +++ b/crates/lint/src/linter.rs @@ -0,0 +1,136 @@ +use foundry_compilers::Language; +use foundry_config::lint::Severity; +use solar_ast::{visit::Visit, Expr, ItemFunction, ItemStruct, VariableDefinition}; +use solar_interface::{ + data_structures::Never, + diagnostics::{DiagBuilder, DiagId, MultiSpan}, + Session, Span, +}; +use std::{ops::ControlFlow, path::PathBuf}; + +/// Trait representing a generic linter for analyzing and reporting issues in smart contract source +/// code files. A linter can be implemented for any smart contract language supported by Foundry. +/// +/// # Type Parameters +/// +/// - `Language`: Represents the target programming language. Must implement the [`Language`] trait. +/// - `Lint`: Represents the types of lints performed by the linter. Must implement the [`Lint`] +/// trait. +/// +/// # Required Methods +/// +/// - `lint`: Scans the provided source files emitting a daignostic for lints found. +pub trait Linter: Send + Sync + Clone { + type Language: Language; + type Lint: Lint; + + fn lint(&self, input: &[PathBuf]); +} + +pub trait Lint { + fn id(&self) -> &'static str; + fn severity(&self) -> Severity; + fn description(&self) -> &'static str; + fn help(&self) -> Option<&'static str> { + None + } +} + +pub struct LintContext<'s> { + sess: &'s Session, + desc: bool, +} + +impl<'s> LintContext<'s> { + pub fn new(sess: &'s Session, with_description: bool) -> Self { + Self { sess, desc: with_description } + } + + // Helper method to emit diagnostics easily from passes + pub fn emit(&self, lint: &'static L, span: Span) { + let (desc, help) = match (self.desc, lint.help()) { + (true, Some(help)) => (lint.description(), help), + (true, None) => (lint.description(), ""), + (false, _) => ("", ""), + }; + + let diag: DiagBuilder<'_, ()> = self + .sess + .dcx + .diag(lint.severity().into(), desc) + .code(DiagId::new_str(lint.id())) + .span(MultiSpan::from_span(span)) + .help(help); + + diag.emit(); + } +} + +/// Trait for lints that operate directly on the AST. +/// Its methods mirror `solar_ast::visit::Visit`, with the addition of `LintCotext`. +pub trait EarlyLintPass<'ast>: Send + Sync { + fn check_expr(&mut self, _ctx: &LintContext<'_>, _expr: &'ast Expr<'ast>) {} + fn check_item_struct(&mut self, _ctx: &LintContext<'_>, _struct: &'ast ItemStruct<'ast>) {} + fn check_item_function(&mut self, _ctx: &LintContext<'_>, _func: &'ast ItemFunction<'ast>) {} + fn check_variable_definition( + &mut self, + _ctx: &LintContext<'_>, + _var: &'ast VariableDefinition<'ast>, + ) { + } + + // TODO: Add methods for each required AST node type +} + +/// Visitor struct for `EarlyLintPass`es +pub struct EarlyLintVisitor<'a, 's, 'ast> { + pub ctx: &'a LintContext<'s>, + pub passes: &'a mut [Box + 's>], +} + +impl<'s, 'ast> Visit<'ast> for EarlyLintVisitor<'_, 's, 'ast> +where + 's: 'ast, +{ + type BreakValue = Never; + + fn visit_expr(&mut self, expr: &'ast Expr<'ast>) -> ControlFlow { + for pass in self.passes.iter_mut() { + pass.check_expr(self.ctx, expr) + } + self.walk_expr(expr) + } + + fn visit_variable_definition( + &mut self, + var: &'ast VariableDefinition<'ast>, + ) -> ControlFlow { + for pass in self.passes.iter_mut() { + pass.check_variable_definition(self.ctx, var) + } + self.walk_variable_definition(var) + } + + fn visit_item_struct( + &mut self, + strukt: &'ast ItemStruct<'ast>, + ) -> ControlFlow { + for pass in self.passes.iter_mut() { + pass.check_item_struct(self.ctx, strukt) + } + self.walk_item_struct(strukt) + } + + fn visit_item_function( + &mut self, + func: &'ast ItemFunction<'ast>, + ) -> ControlFlow { + for pass in self.passes.iter_mut() { + pass.check_item_function(self.ctx, func) + } + self.walk_item_function(func) + } + + // TODO: Add methods for each required AST node type, mirroring `solar_ast::visit::Visit` method + // sigs + adding `LintContext` +} diff --git a/crates/lint/src/sol/gas/keccak.rs b/crates/lint/src/sol/gas/keccak.rs new file mode 100644 index 0000000000000..68ffeb8da264c --- /dev/null +++ b/crates/lint/src/sol/gas/keccak.rs @@ -0,0 +1,29 @@ +use solar_ast::{Expr, ExprKind}; +use solar_interface::kw; + +use super::AsmKeccak256; +use crate::{ + declare_forge_lint, + linter::EarlyLintPass, + sol::{Severity, SolLint}, +}; + +declare_forge_lint!( + ASM_KECCAK256, + Severity::Gas, + "asm-keccak256", + "hash using inline assembly to save gas", + "" +); + +impl<'ast> EarlyLintPass<'ast> for AsmKeccak256 { + fn check_expr(&mut self, ctx: &crate::linter::LintContext<'_>, expr: &'ast Expr<'ast>) { + if let ExprKind::Call(expr, _) = &expr.kind { + if let ExprKind::Ident(ident) = &expr.kind { + if ident.name == kw::Keccak256 { + ctx.emit(&ASM_KECCAK256, expr.span); + } + } + } + } +} diff --git a/crates/lint/src/sol/gas/mod.rs b/crates/lint/src/sol/gas/mod.rs new file mode 100644 index 0000000000000..7c25c5b771eba --- /dev/null +++ b/crates/lint/src/sol/gas/mod.rs @@ -0,0 +1,9 @@ +mod keccak; +use keccak::ASM_KECCAK256; + +use crate::{ + register_lints, + sol::{EarlyLintPass, SolLint}, +}; + +register_lints!((AsmKeccak256, (ASM_KECCAK256))); diff --git a/crates/lint/src/sol/high/incorrect_shift.rs b/crates/lint/src/sol/high/incorrect_shift.rs new file mode 100644 index 0000000000000..632a8aa7f1d7f --- /dev/null +++ b/crates/lint/src/sol/high/incorrect_shift.rs @@ -0,0 +1,44 @@ +use solar_ast::{BinOp, BinOpKind, Expr, ExprKind}; + +use super::IncorrectShift; +use crate::{ + declare_forge_lint, + linter::{EarlyLintPass, LintContext}, + sol::{Severity, SolLint}, +}; + +declare_forge_lint!( + INCORRECT_SHIFT, + Severity::High, + "incorrect-shift", + "the order of args in a shift operation is incorrect", + "" +); + +impl<'ast> EarlyLintPass<'ast> for IncorrectShift { + fn check_expr(&mut self, ctx: &LintContext<'_>, expr: &'ast Expr<'ast>) { + if let ExprKind::Binary( + left_expr, + BinOp { kind: BinOpKind::Shl | BinOpKind::Shr, .. }, + right_expr, + ) = &expr.kind + { + if contains_incorrect_shift(left_expr, right_expr) { + ctx.emit(&INCORRECT_SHIFT, expr.span); + } + } + } +} + +// TODO: come up with a better heuristic. Treat initial impl as a PoC. +// Checks if the left operand is a literal and the right operand is not, indicating a potential +// reversed shift operation. +fn contains_incorrect_shift<'ast>( + left_expr: &'ast Expr<'ast>, + right_expr: &'ast Expr<'ast>, +) -> bool { + let is_left_literal = matches!(left_expr.kind, ExprKind::Lit(..)); + let is_right_not_literal = !matches!(right_expr.kind, ExprKind::Lit(..)); + + is_left_literal && is_right_not_literal +} diff --git a/crates/lint/src/sol/high/mod.rs b/crates/lint/src/sol/high/mod.rs new file mode 100644 index 0000000000000..475ada5f24685 --- /dev/null +++ b/crates/lint/src/sol/high/mod.rs @@ -0,0 +1,9 @@ +mod incorrect_shift; +use incorrect_shift::INCORRECT_SHIFT; + +use crate::{ + register_lints, + sol::{EarlyLintPass, SolLint}, +}; + +register_lints!((IncorrectShift, (INCORRECT_SHIFT))); diff --git a/crates/lint/src/sol/info/mixed_case.rs b/crates/lint/src/sol/info/mixed_case.rs new file mode 100644 index 0000000000000..c4245282aedfd --- /dev/null +++ b/crates/lint/src/sol/info/mixed_case.rs @@ -0,0 +1,64 @@ +use solar_ast::{ItemFunction, VariableDefinition}; + +use super::{MixedCaseFunction, MixedCaseVariable}; +use crate::{ + declare_forge_lint, + linter::{EarlyLintPass, LintContext}, + sol::{Severity, SolLint}, +}; + +declare_forge_lint!( + MIXED_CASE_FUNCTION, + Severity::Info, + "mixed-case-function", + "function names should use mixedCase", + "https://docs.soliditylang.org/en/latest/style-guide.html#function-names" +); + +impl<'ast> EarlyLintPass<'ast> for MixedCaseFunction { + fn check_item_function(&mut self, ctx: &LintContext<'_>, func: &'ast ItemFunction<'ast>) { + if let Some(name) = func.header.name { + let name = name.as_str(); + if !is_mixed_case(name) && name.len() > 1 { + ctx.emit(&MIXED_CASE_FUNCTION, func.body_span); + } + } + } +} + +declare_forge_lint!( + MIXED_CASE_VARIABLE, + Severity::Info, + "mixed-case-variable", + "mutable variables should use mixedCase" +); + +impl<'ast> EarlyLintPass<'ast> for MixedCaseVariable { + fn check_variable_definition( + &mut self, + ctx: &LintContext<'_>, + var: &'ast VariableDefinition<'ast>, + ) { + if var.mutability.is_none() { + if let Some(name) = var.name { + let name = name.as_str(); + if !is_mixed_case(name) { + ctx.emit(&MIXED_CASE_VARIABLE, var.span); + } + } + } + } +} + +/// Check if a string is mixedCase +/// +/// To avoid false positives like `fn increment()` or `uint256 counter`, +/// lowercase strings are treated as mixedCase. +pub fn is_mixed_case(s: &str) -> bool { + if s.len() <= 1 { + return true; + } + + // Remove leading/trailing underscores like `heck` does + s.trim_matches('_') == format!("{}", heck::AsLowerCamelCase(s)).as_str() +} diff --git a/crates/lint/src/sol/info/mod.rs b/crates/lint/src/sol/info/mod.rs new file mode 100644 index 0000000000000..9988d60586833 --- /dev/null +++ b/crates/lint/src/sol/info/mod.rs @@ -0,0 +1,20 @@ +mod mixed_case; +use mixed_case::{MIXED_CASE_FUNCTION, MIXED_CASE_VARIABLE}; + +mod pascal_case; +use pascal_case::PASCAL_CASE_STRUCT; + +mod screaming_snake_case; +use screaming_snake_case::{SCREAMING_SNAKE_CASE_CONSTANT, SCREAMING_SNAKE_CASE_IMMUTABLE}; + +use crate::{ + register_lints, + sol::{EarlyLintPass, SolLint}, +}; + +register_lints!( + (PascalCaseStruct, (PASCAL_CASE_STRUCT)), + (MixedCaseVariable, (MIXED_CASE_VARIABLE)), + (MixedCaseFunction, (MIXED_CASE_FUNCTION)), + (ScreamingSnakeCase, (SCREAMING_SNAKE_CASE_CONSTANT, SCREAMING_SNAKE_CASE_IMMUTABLE)) +); diff --git a/crates/lint/src/sol/info/pascal_case.rs b/crates/lint/src/sol/info/pascal_case.rs new file mode 100644 index 0000000000000..861a4834dfcc9 --- /dev/null +++ b/crates/lint/src/sol/info/pascal_case.rs @@ -0,0 +1,34 @@ +use solar_ast::ItemStruct; + +use super::PascalCaseStruct; +use crate::{ + declare_forge_lint, + linter::{EarlyLintPass, LintContext}, + sol::{Severity, SolLint}, +}; + +declare_forge_lint!( + PASCAL_CASE_STRUCT, + Severity::Info, + "pascal-case-struct", + "structs should use PascalCase", + "https://docs.soliditylang.org/en/latest/style-guide.html#struct-names" +); + +impl<'ast> EarlyLintPass<'ast> for PascalCaseStruct { + fn check_item_struct(&mut self, ctx: &LintContext<'_>, strukt: &'ast ItemStruct<'ast>) { + let name = strukt.name.as_str(); + if !is_pascal_case(name) && name.len() > 1 { + ctx.emit(&PASCAL_CASE_STRUCT, strukt.name.span); + } + } +} + +/// Check if a string is PascalCase +pub fn is_pascal_case(s: &str) -> bool { + if s.len() <= 1 { + return true; + } + + s == format!("{}", heck::AsPascalCase(s)).as_str() +} diff --git a/crates/lint/src/sol/info/screaming_snake_case.rs b/crates/lint/src/sol/info/screaming_snake_case.rs new file mode 100644 index 0000000000000..6703cea425b2c --- /dev/null +++ b/crates/lint/src/sol/info/screaming_snake_case.rs @@ -0,0 +1,53 @@ +use solar_ast::{VarMut, VariableDefinition}; + +use super::ScreamingSnakeCase; +use crate::{ + declare_forge_lint, + linter::{EarlyLintPass, LintContext}, + sol::{Severity, SolLint}, +}; + +declare_forge_lint!( + SCREAMING_SNAKE_CASE_CONSTANT, + Severity::Info, + "screaming-snake-case-const", + "constants should use SCREAMING_SNAKE_CASE", + "https://docs.soliditylang.org/en/latest/style-guide.html#constants" +); + +declare_forge_lint!( + SCREAMING_SNAKE_CASE_IMMUTABLE, + Severity::Info, + "screaming-snake-case-immutable", + "immutables should use SCREAMING_SNAKE_CASE" +); + +impl<'ast> EarlyLintPass<'ast> for ScreamingSnakeCase { + fn check_variable_definition( + &mut self, + ctx: &LintContext<'_>, + var: &'ast VariableDefinition<'ast>, + ) { + if let (Some(name), Some(mutability)) = (var.name, var.mutability) { + let name_str = name.as_str(); + if name_str.len() < 2 || is_screaming_snake_case(name_str) { + return; + } + + match mutability { + VarMut::Constant => ctx.emit(&SCREAMING_SNAKE_CASE_CONSTANT, name.span), + VarMut::Immutable => ctx.emit(&SCREAMING_SNAKE_CASE_IMMUTABLE, name.span), + } + } + } +} + +/// Check if a string is SCREAMING_SNAKE_CASE. Numbers don't need to be preceded by an underscore. +pub fn is_screaming_snake_case(s: &str) -> bool { + if s.len() <= 1 { + return true; + } + + // Remove leading/trailing underscores like `heck` does + s.trim_matches('_') == format!("{}", heck::AsShoutySnakeCase(s)).as_str() +} diff --git a/crates/lint/src/sol/macros.rs b/crates/lint/src/sol/macros.rs new file mode 100644 index 0000000000000..093b96a7e4a70 --- /dev/null +++ b/crates/lint/src/sol/macros.rs @@ -0,0 +1,67 @@ +/// Macro for defining lints and relevant metadata for the Solidity linter. +/// +/// # Parameters +/// +/// Each lint requires the following input fields: +/// - `$id`: Identitifier of the generated `SolLint` constant. +/// - `$severity`: The `Severity` of the lint (e.g. `High`, `Med`, `Low`, `Info`, `Gas`). +/// - `$str_id`: A unique identifier used to reference a specific lint during configuration. +/// - `$desc`: A short description of the lint. +/// - `$help` (optional): Link to additional information about the lint or best practices. +#[macro_export] +macro_rules! declare_forge_lint { + ($id:ident, $severity:expr, $str_id:expr, $desc:expr, $help:expr) => { + // Declare the static `Lint` metadata + pub static $id: SolLint = SolLint { + id: $str_id, + severity: $severity, + description: $desc, + help: if $help.is_empty() { None } else { Some($help) }, + }; + }; + + ($id:ident, $severity:expr, $str_id:expr, $desc:expr) => { + $crate::declare_forge_lint!($id, $severity, $str_id, $desc, ""); + }; +} + +/// Registers Solidity linter passes with their corresponding `SolLint`. +/// +/// # Parameters +/// +/// - `$pass_id`: Identitifier of the generated struct that will implement the pass trait. +/// - (`$lint`): tuple with `SolLint` constants that should be evaluated on every input that pass. +/// +/// # Outputs +/// +/// - Structs for each linting pass (which should manually implement `EarlyLintPass`) +/// - `const REGISTERED_LINTS` containing all registered lint objects +/// - `const LINT_PASSES` mapping each lint to its corresponding pass +#[macro_export] +macro_rules! register_lints { + ( $( ($pass_id:ident, ($($lint:expr),+ $(,)?)) ),* $(,)? ) => { + // Declare the structs that will implement the pass trait + $( + #[derive(Debug, Default, Clone, Copy, Eq, PartialEq)] + pub struct $pass_id; + + impl $pass_id { + pub fn as_lint_pass<'a>() -> Box> { + Box::new(Self::default()) + } + } + )* + + // Expose array constants + pub const REGISTERED_LINTS: &[SolLint] = &[$( $($lint,) + )*]; + pub const LINT_PASSES: &[(SolLint, fn() -> Box>)] = &[ + $( $( ($lint, || Box::new($pass_id::default())), )+ )* + ]; + + // Helper function to create lint passes with the required lifetime + pub fn create_lint_passes<'a>() -> Vec<(Box>, SolLint)> + { + vec![ $( $(($pass_id::as_lint_pass(), $lint), )+ )* ] + } + }; +} diff --git a/crates/lint/src/sol/med/div_mul.rs b/crates/lint/src/sol/med/div_mul.rs new file mode 100644 index 0000000000000..eb8bf185ac079 --- /dev/null +++ b/crates/lint/src/sol/med/div_mul.rs @@ -0,0 +1,40 @@ +use solar_ast::{BinOp, BinOpKind, Expr, ExprKind}; + +use super::DivideBeforeMultiply; +use crate::{ + declare_forge_lint, + linter::{EarlyLintPass, LintContext}, + sol::{Severity, SolLint}, +}; + +declare_forge_lint!( + DIVIDE_BEFORE_MULTIPLY, + Severity::Med, + "divide-before-multiply", + "multiplication should occur before division to avoid loss of precision", + "" +); + +impl<'ast> EarlyLintPass<'ast> for DivideBeforeMultiply { + fn check_expr(&mut self, ctx: &LintContext<'_>, expr: &'ast Expr<'ast>) { + if let ExprKind::Binary(left_expr, BinOp { kind: BinOpKind::Mul, .. }, _) = &expr.kind { + if contains_division(left_expr) { + ctx.emit(&DIVIDE_BEFORE_MULTIPLY, expr.span); + } + } + } +} + +fn contains_division<'ast>(expr: &'ast Expr<'ast>) -> bool { + match &expr.kind { + ExprKind::Binary(_, BinOp { kind: BinOpKind::Div, .. }, _) => true, + ExprKind::Tuple(inner_exprs) => inner_exprs.iter().any(|opt_expr| { + if let Some(inner_expr) = opt_expr { + contains_division(inner_expr) + } else { + false + } + }), + _ => false, + } +} diff --git a/crates/lint/src/sol/med/mod.rs b/crates/lint/src/sol/med/mod.rs new file mode 100644 index 0000000000000..d81ac6c4df242 --- /dev/null +++ b/crates/lint/src/sol/med/mod.rs @@ -0,0 +1,9 @@ +mod div_mul; +use div_mul::DIVIDE_BEFORE_MULTIPLY; + +use crate::{ + register_lints, + sol::{EarlyLintPass, SolLint}, +}; + +register_lints!((DivideBeforeMultiply, (DIVIDE_BEFORE_MULTIPLY))); diff --git a/crates/lint/src/sol/mod.rs b/crates/lint/src/sol/mod.rs new file mode 100644 index 0000000000000..6ada6dd33f5ef --- /dev/null +++ b/crates/lint/src/sol/mod.rs @@ -0,0 +1,212 @@ +pub mod macros; + +pub mod gas; +pub mod high; +pub mod info; +pub mod med; + +use crate::linter::{EarlyLintPass, EarlyLintVisitor, Lint, LintContext, Linter}; + +use foundry_compilers::solc::SolcLanguage; +use foundry_config::lint::Severity; +use rayon::iter::{IntoParallelIterator, ParallelIterator}; +use solar_ast::{visit::Visit, Arena}; +use solar_interface::{ + diagnostics::{self, DiagCtxt, JsonEmitter}, + Session, SourceMap, +}; +use std::{ + path::{Path, PathBuf}, + sync::Arc, +}; +use thiserror::Error; + +/// Linter implementation to analyze Solidity source code responsible for identifying +/// vulnerabilities gas optimizations, and best practices. +#[derive(Debug, Clone, Default)] +pub struct SolidityLinter { + severity: Option>, + lints_included: Option>, + lints_excluded: Option>, + with_description: bool, + with_json_emitter: bool, +} + +impl SolidityLinter { + pub fn new() -> Self { + Self { + severity: None, + lints_included: None, + lints_excluded: None, + with_description: true, + with_json_emitter: false, + } + } + + pub fn with_severity(mut self, severity: Option>) -> Self { + self.severity = severity; + self + } + + pub fn with_lints(mut self, lints: Option>) -> Self { + self.lints_included = lints; + self + } + + pub fn without_lints(mut self, lints: Option>) -> Self { + self.lints_excluded = lints; + self + } + + pub fn with_description(mut self, with: bool) -> Self { + self.with_description = with; + self + } + + pub fn with_json_emitter(mut self, with: bool) -> Self { + self.with_json_emitter = with; + self + } + + fn process_file(&self, sess: &Session, file: &Path) { + let arena = Arena::new(); + + let _ = sess.enter(|| -> Result<(), diagnostics::ErrorGuaranteed> { + // Declare all available passes and lints + let mut passes_and_lints = Vec::new(); + passes_and_lints.extend(gas::create_lint_passes()); + passes_and_lints.extend(high::create_lint_passes()); + passes_and_lints.extend(med::create_lint_passes()); + passes_and_lints.extend(info::create_lint_passes()); + + // Filter based on linter config + let mut passes: Vec>> = passes_and_lints + .into_iter() + .filter_map(|(pass, lint)| { + let matches_severity = match self.severity { + Some(ref target) => target.contains(&lint.severity()), + None => true, + }; + let matches_lints_inc = match self.lints_included { + Some(ref target) => target.contains(&lint), + None => true, + }; + let matches_lints_exc = match self.lints_excluded { + Some(ref target) => target.contains(&lint), + None => false, + }; + + if matches_severity && matches_lints_inc && !matches_lints_exc { + Some(pass) + } else { + None + } + }) + .collect(); + + // Initialize the parser and get the AST + let mut parser = solar_parse::Parser::from_file(sess, &arena, file)?; + let ast = parser.parse_file().map_err(|e| e.emit())?; + + // Initialize and run the visitor + let ctx = LintContext::new(sess, self.with_description); + let mut visitor = EarlyLintVisitor { ctx: &ctx, passes: &mut passes }; + _ = visitor.visit_source_unit(&ast); + + Ok(()) + }); + } +} + +impl Linter for SolidityLinter { + type Language = SolcLanguage; + type Lint = SolLint; + + fn lint(&self, input: &[PathBuf]) { + let mut builder = Session::builder(); + + // Build session based on the linter config + if self.with_json_emitter { + let map = Arc::::default(); + let json_emitter = JsonEmitter::new(Box::new(std::io::stderr()), map.clone()) + .rustc_like(true) + .ui_testing(false); + + builder = builder.dcx(DiagCtxt::new(Box::new(json_emitter))).source_map(map); + } else { + builder = builder.with_stderr_emitter(); + }; + + // Create a single session for all files + let mut sess = builder.build(); + sess.dcx = sess.dcx.set_flags(|flags| flags.track_diagnostics = false); + + // Process the files in parallel + sess.enter_parallel(|| { + input.into_par_iter().for_each(|file| { + self.process_file(&sess, file); + }); + }); + } +} + +#[derive(Error, Debug)] +pub enum SolLintError { + #[error("Unknown lint ID: {0}")] + InvalidId(String), +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub struct SolLint { + id: &'static str, + description: &'static str, + help: Option<&'static str>, + severity: Severity, +} + +impl Lint for SolLint { + fn id(&self) -> &'static str { + self.id + } + fn severity(&self) -> Severity { + self.severity + } + fn description(&self) -> &'static str { + self.description + } + fn help(&self) -> Option<&'static str> { + self.help + } +} + +impl<'a> TryFrom<&'a str> for SolLint { + type Error = SolLintError; + + fn try_from(value: &'a str) -> Result { + for &lint in high::REGISTERED_LINTS { + if lint.id() == value { + return Ok(lint); + } + } + + for &lint in med::REGISTERED_LINTS { + if lint.id() == value { + return Ok(lint); + } + } + + for &lint in info::REGISTERED_LINTS { + if lint.id() == value { + return Ok(lint); + } + } + + for &lint in gas::REGISTERED_LINTS { + if lint.id() == value { + return Ok(lint); + } + } + + Err(SolLintError::InvalidId(value.to_string())) + } +} diff --git a/crates/lint/testdata/DivideBeforeMultiply.sol b/crates/lint/testdata/DivideBeforeMultiply.sol new file mode 100644 index 0000000000000..694558bfca363 --- /dev/null +++ b/crates/lint/testdata/DivideBeforeMultiply.sol @@ -0,0 +1,18 @@ +contract DivideBeforeMultiply { + function arithmetic() public { + (1 / 2) * 3; //~WARN: multiplication should occur before division to avoid loss of precision + (1 * 2) / 3; + ((1 / 2) * 3) * 4; //~WARN: multiplication should occur before division to avoid loss of precision + ((1 * 2) / 3) * 4; //~WARN: multiplication should occur before division to avoid loss of precision + (1 / 2 / 3) * 4; //~WARN: multiplication should occur before division to avoid loss of precision + (1 / (2 + 3)) * 4; //~WARN: multiplication should occur before division to avoid loss of precision + (1 / 2 + 3) * 4; + (1 / 2 - 3) * 4; + (1 + 2 / 3) * 4; + (1 / 2 - 3) * 4; + ((1 / 2) % 3) * 4; + 1 / (2 * 3 + 3); + 1 / ((2 / 3) * 3); //~WARN: multiplication should occur before division to avoid loss of precision + 1 / ((2 * 3) + 3); + } +} diff --git a/crates/lint/testdata/DivideBeforeMultiply.stderr b/crates/lint/testdata/DivideBeforeMultiply.stderr new file mode 100644 index 0000000000000..08d8ebe25d1c9 --- /dev/null +++ b/crates/lint/testdata/DivideBeforeMultiply.stderr @@ -0,0 +1,42 @@ +warning[divide-before-multiply]: multiplication should occur before division to avoid loss of precision + --> ROOT/testdata/DivideBeforeMultiply.sol:LL:CC + | +3 | (1 / 2) * 3; + | ----------- + | + +warning[divide-before-multiply]: multiplication should occur before division to avoid loss of precision + --> ROOT/testdata/DivideBeforeMultiply.sol:LL:CC + | +5 | ((1 / 2) * 3) * 4; + | ----------- + | + +warning[divide-before-multiply]: multiplication should occur before division to avoid loss of precision + --> ROOT/testdata/DivideBeforeMultiply.sol:LL:CC + | +6 | ((1 * 2) / 3) * 4; + | ----------------- + | + +warning[divide-before-multiply]: multiplication should occur before division to avoid loss of precision + --> ROOT/testdata/DivideBeforeMultiply.sol:LL:CC + | +7 | (1 / 2 / 3) * 4; + | --------------- + | + +warning[divide-before-multiply]: multiplication should occur before division to avoid loss of precision + --> ROOT/testdata/DivideBeforeMultiply.sol:LL:CC + | +8 | (1 / (2 + 3)) * 4; + | ----------------- + | + +warning[divide-before-multiply]: multiplication should occur before division to avoid loss of precision + --> ROOT/testdata/DivideBeforeMultiply.sol:LL:CC + | +15 | 1 / ((2 / 3) * 3); + | ----------- + | + diff --git a/crates/lint/testdata/IncorrectShift.sol b/crates/lint/testdata/IncorrectShift.sol new file mode 100644 index 0000000000000..9377354851c42 --- /dev/null +++ b/crates/lint/testdata/IncorrectShift.sol @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +contract IncorrectShift { + uint256 stateValue = 100; + uint256 stateShiftAmount = 4; + + function getAmount() public view returns (uint256) { + return stateShiftAmount; + } + + function shift() public view { + uint256 result; + uint256 localValue = 50; + uint256 localShiftAmount = 3; + + // SHOULD FAIL: + // - Literal << NonLiteral + // - Literal >> NonLiteral + + result = 2 << stateValue; //~WARN: the order of args in a shift operation is incorrect + result = 8 >> localValue; //~WARN: the order of args in a shift operation is incorrect + result = 16 << (stateValue + 1); //~WARN: the order of args in a shift operation is incorrect + result = 32 >> getAmount(); //~WARN: the order of args in a shift operation is incorrect + result = 1 << (localValue > 10 ? localShiftAmount : stateShiftAmount); //~WARN: the order of args in a shift operation is incorrect + + // SHOULD PASS: + result = stateValue << 2; + result = localValue >> 3; + result = stateValue << localShiftAmount; + result = localValue >> stateShiftAmount; + result = (stateValue * 2) << 4; + result = getAmount() >> 1; + + result = 1 << 8; + result = 255 >> 4; + } +} diff --git a/crates/lint/testdata/IncorrectShift.stderr b/crates/lint/testdata/IncorrectShift.stderr new file mode 100644 index 0000000000000..16fbda627cd9b --- /dev/null +++ b/crates/lint/testdata/IncorrectShift.stderr @@ -0,0 +1,35 @@ +warning[incorrect-shift]: the order of args in a shift operation is incorrect + --> ROOT/testdata/IncorrectShift.sol:LL:CC + | +21 | result = 2 << stateValue; + | --------------- + | + +warning[incorrect-shift]: the order of args in a shift operation is incorrect + --> ROOT/testdata/IncorrectShift.sol:LL:CC + | +22 | result = 8 >> localValue; + | --------------- + | + +warning[incorrect-shift]: the order of args in a shift operation is incorrect + --> ROOT/testdata/IncorrectShift.sol:LL:CC + | +23 | result = 16 << (stateValue + 1); + | ---------------------- + | + +warning[incorrect-shift]: the order of args in a shift operation is incorrect + --> ROOT/testdata/IncorrectShift.sol:LL:CC + | +24 | result = 32 >> getAmount(); + | ----------------- + | + +warning[incorrect-shift]: the order of args in a shift operation is incorrect + --> ROOT/testdata/IncorrectShift.sol:LL:CC + | +25 | ... result = 1 << (localValue > 10 ? localShiftAmount : stateShiftAmount); + | ------------------------------------------------------------ + | + diff --git a/crates/lint/testdata/Keccak256.sol b/crates/lint/testdata/Keccak256.sol new file mode 100644 index 0000000000000..8d3a3044bc8cd --- /dev/null +++ b/crates/lint/testdata/Keccak256.sol @@ -0,0 +1,18 @@ +contract AsmKeccak256 { + constructor(uint256 a, uint256 b) { + keccak256(abi.encodePacked(a, b)); //~NOTE: hash using inline assembly to save gas + } + + function solidityHash(uint256 a, uint256 b) public view { + keccak256(abi.encodePacked(a, b)); //~NOTE: hash using inline assembly to save gas + } + + function assemblyHash(uint256 a, uint256 b) public view { + //optimized + assembly { + mstore(0x00, a) + mstore(0x20, b) + let hashedVal := keccak256(0x00, 0x40) + } + } +} diff --git a/crates/lint/testdata/Keccak256.stderr b/crates/lint/testdata/Keccak256.stderr new file mode 100644 index 0000000000000..e589bec00eb77 --- /dev/null +++ b/crates/lint/testdata/Keccak256.stderr @@ -0,0 +1,14 @@ +note[asm-keccak256]: hash using inline assembly to save gas + --> ROOT/testdata/Keccak256.sol:LL:CC + | +3 | keccak256(abi.encodePacked(a, b)); + | --------- + | + +note[asm-keccak256]: hash using inline assembly to save gas + --> ROOT/testdata/Keccak256.sol:LL:CC + | +7 | keccak256(abi.encodePacked(a, b)); + | --------- + | + diff --git a/crates/lint/testdata/MixedCase.sol b/crates/lint/testdata/MixedCase.sol new file mode 100644 index 0000000000000..871922867affe --- /dev/null +++ b/crates/lint/testdata/MixedCase.sol @@ -0,0 +1,29 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +contract MixedCaseTest { + uint256 variableMixedCase; + uint256 _variableMixedCase; + uint256 variablemixedcase; + + uint256 Variablemixedcase; //~NOTE: mutable variables should use mixedCase + uint256 VARIABLE_MIXED_CASE; //~NOTE: mutable variables should use mixedCase + uint256 VariableMixedCase; //~NOTE: mutable variables should use mixedCase + + function foo() public { + uint256 testVal; + uint256 testVal123; + + uint256 testVAL; //~NOTE: mutable variables should use mixedCase + uint256 TestVal; //~NOTE: mutable variables should use mixedCase + uint256 TESTVAL; //~NOTE: mutable variables should use mixedCase + } + + function functionMixedCase() public {} + function _functionMixedCase() internal {} + function functionmixedcase() public {} + + function Functionmixedcase() public {} //~NOTE: function names should use mixedCase + function FUNCTION_MIXED_CASE() public {} //~NOTE: function names should use mixedCase + function FunctionMixedCase() public {} //~NOTE: function names should use mixedCase +} diff --git a/crates/lint/testdata/MixedCase.stderr b/crates/lint/testdata/MixedCase.stderr new file mode 100644 index 0000000000000..a5ef41dec2376 --- /dev/null +++ b/crates/lint/testdata/MixedCase.stderr @@ -0,0 +1,66 @@ +note[mixed-case-variable]: mutable variables should use mixedCase + --> ROOT/testdata/MixedCase.sol:LL:CC + | +9 | uint256 Variablemixedcase; + | -------------------------- + | + +note[mixed-case-variable]: mutable variables should use mixedCase + --> ROOT/testdata/MixedCase.sol:LL:CC + | +10 | uint256 VARIABLE_MIXED_CASE; + | ---------------------------- + | + +note[mixed-case-variable]: mutable variables should use mixedCase + --> ROOT/testdata/MixedCase.sol:LL:CC + | +11 | uint256 VariableMixedCase; + | -------------------------- + | + +note[mixed-case-variable]: mutable variables should use mixedCase + --> ROOT/testdata/MixedCase.sol:LL:CC + | +17 | uint256 testVAL; + | --------------- + | + +note[mixed-case-variable]: mutable variables should use mixedCase + --> ROOT/testdata/MixedCase.sol:LL:CC + | +18 | uint256 TestVal; + | --------------- + | + +note[mixed-case-variable]: mutable variables should use mixedCase + --> ROOT/testdata/MixedCase.sol:LL:CC + | +19 | uint256 TESTVAL; + | --------------- + | + +note[mixed-case-function]: function names should use mixedCase + --> ROOT/testdata/MixedCase.sol:LL:CC + | +26 | function Functionmixedcase() public {} + | -- + | + = help: https://docs.soliditylang.org/en/latest/style-guide.html#function-names + +note[mixed-case-function]: function names should use mixedCase + --> ROOT/testdata/MixedCase.sol:LL:CC + | +27 | function FUNCTION_MIXED_CASE() public {} + | -- + | + = help: https://docs.soliditylang.org/en/latest/style-guide.html#function-names + +note[mixed-case-function]: function names should use mixedCase + --> ROOT/testdata/MixedCase.sol:LL:CC + | +28 | function FunctionMixedCase() public {} + | -- + | + = help: https://docs.soliditylang.org/en/latest/style-guide.html#function-names + diff --git a/crates/lint/testdata/ScreamingSnakeCase.sol b/crates/lint/testdata/ScreamingSnakeCase.sol new file mode 100644 index 0000000000000..ccfe596d9e9dd --- /dev/null +++ b/crates/lint/testdata/ScreamingSnakeCase.sol @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +contract ScreamingSnakeCaseTest { + uint256 constant _SCREAMING_SNAKE_CASE = 0; + uint256 constant SCREAMING_SNAKE_CASE = 0; + uint256 constant SCREAMINGSNAKECASE = 0; + + uint256 constant screamingSnakeCase = 0; //~NOTE: constants should use SCREAMING_SNAKE_CASE + uint256 constant screaming_snake_case = 0; //~NOTE: constants should use SCREAMING_SNAKE_CASE + uint256 constant ScreamingSnakeCase = 0; //~NOTE: constants should use SCREAMING_SNAKE_CASE + uint256 constant SCREAMING_snake_case = 0; //~NOTE: constants should use SCREAMING_SNAKE_CASE + + uint256 immutable _SCREAMING_SNAKE_CASE_1 = 0; + uint256 immutable SCREAMING_SNAKE_CASE_1 = 0; + uint256 immutable SCREAMINGSNAKECASE0 = 0; + uint256 immutable SCREAMINGSNAKECASE_ = 0; + + uint256 immutable screamingSnakeCase0 = 0; //~NOTE: immutables should use SCREAMING_SNAKE_CASE + uint256 immutable screaming_snake_case0 = 0; //~NOTE: immutables should use SCREAMING_SNAKE_CASE + uint256 immutable ScreamingSnakeCase0 = 0; //~NOTE: immutables should use SCREAMING_SNAKE_CASE + uint256 immutable SCREAMING_snake_case_0 = 0; //~NOTE: immutables should use SCREAMING_SNAKE_CASE +} diff --git a/crates/lint/testdata/ScreamingSnakeCase.stderr b/crates/lint/testdata/ScreamingSnakeCase.stderr new file mode 100644 index 0000000000000..96c2cb34405b3 --- /dev/null +++ b/crates/lint/testdata/ScreamingSnakeCase.stderr @@ -0,0 +1,60 @@ +note[screaming-snake-case-const]: constants should use SCREAMING_SNAKE_CASE + --> ROOT/testdata/ScreamingSnakeCase.sol:LL:CC + | +9 | uint256 constant screamingSnakeCase = 0; + | ------------------ + | + = help: https://docs.soliditylang.org/en/latest/style-guide.html#constants + +note[screaming-snake-case-const]: constants should use SCREAMING_SNAKE_CASE + --> ROOT/testdata/ScreamingSnakeCase.sol:LL:CC + | +10 | uint256 constant screaming_snake_case = 0; + | -------------------- + | + = help: https://docs.soliditylang.org/en/latest/style-guide.html#constants + +note[screaming-snake-case-const]: constants should use SCREAMING_SNAKE_CASE + --> ROOT/testdata/ScreamingSnakeCase.sol:LL:CC + | +11 | uint256 constant ScreamingSnakeCase = 0; + | ------------------ + | + = help: https://docs.soliditylang.org/en/latest/style-guide.html#constants + +note[screaming-snake-case-const]: constants should use SCREAMING_SNAKE_CASE + --> ROOT/testdata/ScreamingSnakeCase.sol:LL:CC + | +12 | uint256 constant SCREAMING_snake_case = 0; + | -------------------- + | + = help: https://docs.soliditylang.org/en/latest/style-guide.html#constants + +note[screaming-snake-case-immutable]: immutables should use SCREAMING_SNAKE_CASE + --> ROOT/testdata/ScreamingSnakeCase.sol:LL:CC + | +19 | uint256 immutable screamingSnakeCase0 = 0; + | ------------------- + | + +note[screaming-snake-case-immutable]: immutables should use SCREAMING_SNAKE_CASE + --> ROOT/testdata/ScreamingSnakeCase.sol:LL:CC + | +20 | uint256 immutable screaming_snake_case0 = 0; + | --------------------- + | + +note[screaming-snake-case-immutable]: immutables should use SCREAMING_SNAKE_CASE + --> ROOT/testdata/ScreamingSnakeCase.sol:LL:CC + | +21 | uint256 immutable ScreamingSnakeCase0 = 0; + | ------------------- + | + +note[screaming-snake-case-immutable]: immutables should use SCREAMING_SNAKE_CASE + --> ROOT/testdata/ScreamingSnakeCase.sol:LL:CC + | +22 | uint256 immutable SCREAMING_snake_case_0 = 0; + | ---------------------- + | + diff --git a/crates/lint/testdata/StructPascalCase.sol b/crates/lint/testdata/StructPascalCase.sol new file mode 100644 index 0000000000000..0ec638efe8211 --- /dev/null +++ b/crates/lint/testdata/StructPascalCase.sol @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +contract StructPascalCaseTest { + struct PascalCase { + uint256 a; + } + + struct PascalCAse { + uint256 a; + } + + struct _PascalCase { //~NOTE: structs should use PascalCase + uint256 a; + } + + struct pascalCase { //~NOTE: structs should use PascalCase + uint256 a; + } + + struct pascalcase { //~NOTE: structs should use PascalCase + uint256 a; + } + + struct pascal_case { //~NOTE: structs should use PascalCase + uint256 a; + } + + struct PASCAL_CASE { //~NOTE: structs should use PascalCase + uint256 a; + } + + struct PASCALCASE { //~NOTE: structs should use PascalCase + uint256 a; + } +} diff --git a/crates/lint/testdata/StructPascalCase.stderr b/crates/lint/testdata/StructPascalCase.stderr new file mode 100644 index 0000000000000..2ef869c7ccc21 --- /dev/null +++ b/crates/lint/testdata/StructPascalCase.stderr @@ -0,0 +1,48 @@ +note[pascal-case-struct]: structs should use PascalCase + --> ROOT/testdata/StructPascalCase.sol:LL:CC + | +13 | struct _PascalCase { + | ----------- + | + = help: https://docs.soliditylang.org/en/latest/style-guide.html#struct-names + +note[pascal-case-struct]: structs should use PascalCase + --> ROOT/testdata/StructPascalCase.sol:LL:CC + | +17 | struct pascalCase { + | ---------- + | + = help: https://docs.soliditylang.org/en/latest/style-guide.html#struct-names + +note[pascal-case-struct]: structs should use PascalCase + --> ROOT/testdata/StructPascalCase.sol:LL:CC + | +21 | struct pascalcase { + | ---------- + | + = help: https://docs.soliditylang.org/en/latest/style-guide.html#struct-names + +note[pascal-case-struct]: structs should use PascalCase + --> ROOT/testdata/StructPascalCase.sol:LL:CC + | +25 | struct pascal_case { + | ----------- + | + = help: https://docs.soliditylang.org/en/latest/style-guide.html#struct-names + +note[pascal-case-struct]: structs should use PascalCase + --> ROOT/testdata/StructPascalCase.sol:LL:CC + | +29 | struct PASCAL_CASE { + | ----------- + | + = help: https://docs.soliditylang.org/en/latest/style-guide.html#struct-names + +note[pascal-case-struct]: structs should use PascalCase + --> ROOT/testdata/StructPascalCase.sol:LL:CC + | +33 | struct PASCALCASE { + | ---------- + | + = help: https://docs.soliditylang.org/en/latest/style-guide.html#struct-names + diff --git a/crates/test-utils/Cargo.toml b/crates/test-utils/Cargo.toml index b9d7b1ce5f2f6..c6b45b29173e4 100644 --- a/crates/test-utils/Cargo.toml +++ b/crates/test-utils/Cargo.toml @@ -32,6 +32,7 @@ tracing-subscriber = { workspace = true, features = ["env-filter"] } rand.workspace = true snapbox = { version = "0.6", features = ["json", "regex", "term-svg"] } tempfile.workspace = true +ui_test = "0.29.2" # See /Cargo.toml. idna_adapter.workspace = true @@ -39,3 +40,6 @@ idna_adapter.workspace = true [dev-dependencies] tokio.workspace = true foundry-block-explorers.workspace = true + +[patch.crates-io] +ui_test = { git = "https://github.com/oli-obk/ui_test", branch = "main" } diff --git a/crates/test-utils/src/lib.rs b/crates/test-utils/src/lib.rs index c1f717f2c118f..4e326b10fb85b 100644 --- a/crates/test-utils/src/lib.rs +++ b/crates/test-utils/src/lib.rs @@ -30,6 +30,8 @@ pub use util::{TestCommand, TestProject}; mod script; pub use script::{ScriptOutcome, ScriptTester}; +pub mod ui_runner; + // re-exports for convenience pub use foundry_compilers; diff --git a/crates/test-utils/src/ui_runner.rs b/crates/test-utils/src/ui_runner.rs new file mode 100644 index 0000000000000..9f6b87a8055ce --- /dev/null +++ b/crates/test-utils/src/ui_runner.rs @@ -0,0 +1,152 @@ +use std::path::Path; +use ui_test::spanned::Spanned; + +/// Test runner based on `ui_test`. Adapted from `https://github.com/paradigmxyz/solar/tools/tester`. +pub fn run_tests<'a>(cmd: &str, cmd_path: &'a Path, testdata: &'a Path) -> eyre::Result<()> { + ui_test::color_eyre::install()?; + + let mut args = ui_test::Args::test()?; + + // Fast path for `--list`, invoked by `cargo-nextest`. + { + let mut dummy_config = ui_test::Config::dummy(); + dummy_config.with_args(&args); + if ui_test::nextest::emulate(&mut vec![dummy_config]) { + return Ok(()); + } + } + + // Condense output if not explicitly requested. + let requested_pretty = || std::env::args().any(|x| x.contains("--format")); + if matches!(args.format, ui_test::Format::Pretty) && !requested_pretty() { + args.format = ui_test::Format::Terse; + } + + let config = config(cmd, cmd_path, &args, testdata); + + let text_emitter = match args.format { + ui_test::Format::Terse => ui_test::status_emitter::Text::quiet(), + ui_test::Format::Pretty => ui_test::status_emitter::Text::verbose(), + }; + let gha_emitter = ui_test::status_emitter::Gha:: { name: "Foundry Lint UI".to_string() }; + let status_emitter = (text_emitter, gha_emitter); + + // run tests on all .sol files + ui_test::run_tests_generic( + vec![config], + move |path, _config| Some(path.extension().is_some_and(|ext| ext == "sol")), + per_file_config, + status_emitter, + )?; + + Ok(()) +} + +fn config<'a>( + cmd: &str, + cmd_path: &'a Path, + args: &ui_test::Args, + testdata: &'a Path, +) -> ui_test::Config { + let root = testdata.parent().unwrap(); + assert!( + testdata.exists(), + "testdata directory does not exist: {};\n\ + you may need to initialize submodules: `git submodule update --init --checkout`", + testdata.display() + ); + + let mut config = ui_test::Config { + host: Some(get_host().to_string()), + target: None, + root_dir: testdata.into(), + program: ui_test::CommandBuilder { + program: cmd_path.into(), + args: { + let args = vec![cmd, "--json"]; + args.into_iter().map(Into::into).collect() + }, + out_dir_flag: None, + input_file_flag: None, + envs: vec![], + cfg_flag: None, + }, + output_conflict_handling: ui_test::error_on_output_conflict, + bless_command: Some(format!("cargo nextest run {} -- --bless", module_path!())), + out_dir: root.join("target").join("ui"), + comment_start: "//", + diagnostic_extractor: ui_test::diagnostics::rustc::rustc_diagnostics_extractor, + ..ui_test::Config::dummy() + }; + + macro_rules! register_custom_flags { + ($($ty:ty),* $(,)?) => { + $( + config.custom_comments.insert(<$ty>::NAME, <$ty>::parse); + if let Some(default) = <$ty>::DEFAULT { + config.comment_defaults.base().add_custom(<$ty>::NAME, default); + } + )* + }; + } + register_custom_flags![]; + + config.comment_defaults.base().exit_status = None.into(); + config.comment_defaults.base().require_annotations = Spanned::dummy(true).into(); + config.comment_defaults.base().require_annotations_for_level = + Spanned::dummy(ui_test::diagnostics::Level::Warn).into(); + + let filters = [ + (ui_test::Match::PathBackslash, b"/".to_vec()), + #[cfg(windows)] + (ui_test::Match::Exact(vec![b'\r']), b"".to_vec()), + #[cfg(windows)] + (ui_test::Match::Exact(br"\\?\".to_vec()), b"".to_vec()), + (root.into(), b"ROOT".to_vec()), + ]; + config.comment_defaults.base().normalize_stderr.extend(filters.iter().cloned()); + config.comment_defaults.base().normalize_stdout.extend(filters); + + let filters: &[(&str, &str)] = &[ + // Erase line and column info. + (r"\.(\w+):[0-9]+:[0-9]+(: [0-9]+:[0-9]+)?", ".$1:LL:CC"), + ]; + for &(pattern, replacement) in filters { + config.filter(pattern, replacement); + } + + let stdout_filters: &[(&str, &str)] = + &[(&env!("CARGO_PKG_VERSION").replace(".", r"\."), "VERSION")]; + for &(pattern, replacement) in stdout_filters { + config.stdout_filter(pattern, replacement); + } + let stderr_filters: &[(&str, &str)] = &[]; + for &(pattern, replacement) in stderr_filters { + config.stderr_filter(pattern, replacement); + } + + config.with_args(args); + config +} + +fn per_file_config(config: &mut ui_test::Config, file: &Spanned>) { + let Ok(src) = std::str::from_utf8(&file.content) else { + return; + }; + + assert_eq!(config.comment_start, "//"); + let has_annotations = src.contains("//~"); + config.comment_defaults.base().require_annotations = Spanned::dummy(has_annotations).into(); + let code = if has_annotations && src.contains("ERROR:") { 1 } else { 0 }; + config.comment_defaults.base().exit_status = Spanned::dummy(code).into(); +} + +fn get_host() -> &'static str { + static CACHE: std::sync::OnceLock = std::sync::OnceLock::new(); + CACHE.get_or_init(|| { + let mut config = ui_test::Config::dummy(); + config.program = ui_test::CommandBuilder::rustc(); + config.fill_host_and_target().unwrap(); + config.host.unwrap() + }) +} diff --git a/docs/dev/lintrules.md b/docs/dev/lintrules.md new file mode 100644 index 0000000000000..0a0f81fe325a9 --- /dev/null +++ b/docs/dev/lintrules.md @@ -0,0 +1,78 @@ +# Linter (`lint`) + +Solidity linter for identifying potential errors, vulnerabilities, gas optimizations, and style guide violations. +It helps enforce best practices and improve code quality within Foundry projects. + +## Architecture + +The `forge-lint` system operates by analyzing Solidity source code: + +1. **Parsing**: Solidity source files are parsed into an Abstract Syntax Tree (AST) using `solar-parse`. This AST represents the structure of the code. +2. **AST Traversal**: The generated AST is then traversed using a Visitor pattern. The `EarlyLintVisitor` is responsible for walking through the AST nodes. +3. **Applying Lint Passes**: As the visitor encounters different AST nodes (like functions, expressions, variable definitions), it invokes registered "lint passes" (`EarlyLintPass` implementations). Each pass is designed to check for a specific code pattern. +4. **Emitting Diagnostics**: If a lint pass identifies a violation of its rule, it uses the `LintContext` to emit a diagnostic (either `warning` or `note`) that pinpoints the issue in the source code. + +### Key Components + +* **`Linter` Trait**: Defines a generic interface for linters. `SolidityLinter` is the concrete implementation tailored for Solidity. +* **`Lint` Trait & `SolLint` Struct**: + * `Lint`: A trait that defines the essential properties of a lint rule, such as its unique ID, severity, description, and an optional help message/URL. + * `SolLint`: A struct implementing the `Lint` trait, used to hold the metadata for each specific Solidity lint rule. +* **`EarlyLintPass<'ast>` Trait**: Lints that operate directly on AST nodes implement this trait. It contains methods (like `check_expr`, `check_item_function`, etc.) called by the visitor. +* **`LintContext<'s>`**: Provides contextual information to lint passes during execution, such as access to the session for emitting diagnostics. +* **`EarlyLintVisitor<'a, 's, 'ast>`**: The core visitor that traverses the AST and dispatches checks to the registered `EarlyLintPass` instances. + +## Developing a new lint rule + +1. Specify an issue that is being addressed in the PR description. +2. In your PR: + * Create a static `SolLint` instance using the `declare_forge_lint!` to define its metadata. + ```rust + declare_forge_lint!( + MIXED_CASE_FUNCTION, // The Rust identifier for this SolLint static + Severity::Info, // The default severity of the lint + "mixed-case-function", // A unique string ID for configuration/CLI + "function names should use mixedCase", // A brief description + "https://docs.soliditylang.org/en/latest/style-guide.html#function-names" // Optional help link + ); + ``` + + * Register the pass struct and the lint using `register_lints!` in the `mod.rs` of its corresponding severity category. This macro generates the necessary boilerplate to make them discoverable by the linter, and creates helper functions to instantiate them. + ```rust + register_lints!( + (PascalCaseStruct, (PASCAL_CASE_STRUCT)), + (MixedCaseVariable, (MIXED_CASE_VARIABLE)), + (MixedCaseFunction, (MIXED_CASE_FUNCTION)) + ); + // The structs `PascalCaseStruct`, `MixedCaseVariable`, etc., would have to manually implement `EarlyLintPass`. + ``` + + * Implement the `EarlyLintPass` trait logic for the pass struct. Do it in a new file within the relevant severity module (e.g., `src/sol/med/my_new_lint.rs`). + +3. Add comprehensive tests in `lint/testdata/`: + * Create `MyNewLint.sol` with various examples (triggering and non-triggering cases, edge cases). + * Generate the corresponding blessed file with the expected output. + +### Testing a lint rule + +Tests are located in the `lint/testdata/` directory. A test for a lint rule involves: + + - A Solidity source file with various code snippets, some of which are expected to trigger the lint. Expected diagnostics must be indicated with either `//~WARN: description` or `//~NOTE: description` on the relevant line. + - corresponding `.stderr` (blessed) file which contains the exact diagnostic output the linter is expected to produce for that source file. + +The testing framework runs the linter on the `.sol` file and compares its standard error output against the content of the `.stderr` file to ensure correctness. + +- Run the following command to trigger the ui test runner: + ```sh + // using the default cargo cmd for running tests + cargo test -p forge --test ui + + // using nextest + cargo nextest run -p forge test ui + ``` + +- If you need to generate the blessed files: + ```sh + // using the default cargo cmd for running tests + cargo test -p forge --test ui -- --bless + ```