diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index 52b5114..00181fb 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -1,7 +1,6 @@ name: CI on: - pull_request: push: jobs: @@ -13,6 +12,14 @@ jobs: - name: Install Nix uses: cachix/install-nix-action@v27 + with: + install_url: https://releases.nixos.org/nix/nix-2.17.0/install + + - name: Nix config + run: | + echo "============================" + cat /etc/nix/nix.conf + echo "============================" - name: Run tests run: nix shell -c ./run-tests.py diff --git a/.gitignore b/.gitignore index c91e69e..efdbcfb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ -/rust-checks/target +/.direnv /result* +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..edb0f7f --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,454 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "aho-corasick" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "0.6.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418c75fa768af9c03be99d17643f93f79bbba589895012a80e3452a19ddda15b" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "038dfcf04a5feb68e9c60b21c9625a54c2c0616e79b72b0fd87075a056ae1d1b" + +[[package]] +name = "anstyle-parse" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c03a11a9034d92058ceb6ee011ce58af4a9bf61491aa7e1e59ecd24bd40d22d4" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64c907d4e79225ac72e2a354c9ce84d50ebb4586dee56c82b3ee73004f537f5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61a38449feb7068f52bb06c12759005cf459ee52bb4adc1d5a7c4322d716fb19" +dependencies = [ + "anstyle", + "windows-sys", +] + +[[package]] +name = "autocfg" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" + +[[package]] +name = "clap" +version = "4.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bc066a67923782aa8515dbaea16946c5bcc5addbd668bb80af688e53e548a0" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae129e2e766ae0ec03484e609954119f123cc1fe650337e155d03b022f24f7b4" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528131438037fd55894f62d6e9f068b8f45ac57ffa77517819645d10aed04f64" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98cc8fbded0c607b7ba9dd60cd98df59af97e84d24e49c8557331cfc26d301ce" + +[[package]] +name = "codespan" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3362992a0d9f1dd7c3d0e89e0ab2bb540b7a95fea8cd798090e758fda2899b5e" +dependencies = [ + "codespan-reporting", +] + +[[package]] +name = "codespan-reporting" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +dependencies = [ + "termcolor", + "unicode-width", +] + +[[package]] +name = "colorchoice" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b6a852b24ab71dffc585bcb46eaf7959d175cb865a7152e35b348d1b2960422" + +[[package]] +name = "countme" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7704b5fdd17b18ae31c4c1da5a2e0305a2bf17b5249300a9ee9ed7b72114c636" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indoc" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b248f5224d1d606005e02c97f5aa4e88eeb230488bcc03bc9ca4d7991399f2b5" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8478577c03552c21db0e2724ffb8986a5ce7af88107e6be5d2ee6e158c12800" + +[[package]] +name = "itoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" + +[[package]] +name = "memchr" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8640c5d730cb13ebd907d8d04b52f55ac9a2eec55b440c8892f40d56c76c1d" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "nixpkgs-hammering" +version = "0.1.0" +dependencies = [ + "clap", + "indoc", + "rust-checks", + "serde", + "serde_json", +] + +[[package]] +name = "proc-macro2" +version = "1.0.84" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec96c6a92621310b51366f1e28d05ef11489516e93be030060e5fc12024a49d6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "regex" +version = "1.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c117dbdfde9c8308975b6a18d71f3f385c89461f7b3fb054288ecf2a2058ba4c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b83b8b9847f9bf95ef68afb0b8e6cdb80f498442f5179a29fad448fcc1eaea" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adad44e29e4c806119491a7f06f03de4d1af22c3a680dd47f1e6e179439d1f56" + +[[package]] +name = "rnix" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb35cedbeb70e0ccabef2a31bcff0aebd114f19566086300b8f42c725fc2cb5f" +dependencies = [ + "rowan", +] + +[[package]] +name = "rowan" +version = "0.15.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a58fa8a7ccff2aec4f39cc45bf5f985cec7125ab271cf681c279fd00192b49" +dependencies = [ + "countme", + "hashbrown", + "memoffset", + "rustc-hash", + "serde", + "text-size", +] + +[[package]] +name = "rust-checks" +version = "0.0.0" +dependencies = [ + "codespan", + "regex", + "rnix", + "rowan", + "serde", + "serde_json", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "ryu" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" + +[[package]] +name = "serde" +version = "1.0.203" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7253ab4de971e72fb7be983802300c30b5a7f0c2e56fab8abfc6a214307c0094" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.203" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "500cbc0ebeb6f46627f50f3f5811ccf6bf00643be300b4c3eabc0ef55dc5b5ba" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "455182ea6142b14f93f4bc5320a2b31c1f266b66a4a5c858b013302a5d8cbfc3" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c42f3f41a2de00b01c0aaad383c5a45241efc8b2d1eda5661812fda5f3cdcff5" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "text-size" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233" +dependencies = [ + "serde", +] + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "unicode-width" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f5e5f3158ecfd4b8ff6fe086db7c8467a2dfdac97fe420f2b7c4aa97af66d6" + +[[package]] +name = "utf8parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" + +[[package]] +name = "winapi-util" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d4cc384e1e73b93bafa6fb4f1df8c41695c8a91cf9c4c64358067d15a7b6c6b" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f0713a46559409d202e70e28227288446bf7841d3211583a4b53e3f6d96e7eb" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7088eed71e8b8dda258ecc8bac5fb1153c5cffaf2578fc8ff5d61e23578d3263" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9985fd1504e250c615ca5f281c3f7a6da76213ebd5ccc9561496568a2752afb6" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88ba073cf16d5372720ec942a8ccbf61626074c6d4dd2e745299726ce8b89670" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87f4261229030a858f36b459e748ae97545d6f1ec60e5e0d6a3d32e0dc232ee9" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db3c2bf3d13d5b658be73463284eaf12830ac9a26a90c717b7f771dfe97487bf" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e4246f76bdeff09eb48875a0fd3e2af6aada79d409d33011886d3e1581517d9" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "852298e482cd67c356ddd9570386e2862b5673c85bd5f88df9ab6802b334c596" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..b389031 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "nixpkgs-hammering" +version = "0.1.0" +edition = "2021" +authors = ["Jan Tojnar "] + +[[bin]] +name = "nixpkgs-hammer" +path = "src/main.rs" + +[dependencies] +clap = { version = "4.5.4", features = ["derive"] } +indoc = "2.0.5" +serde = { workspace = true } +serde_json = { workspace = true } +rust-checks = { path = "./rust-checks" } + +[workspace] +members = ["rust-checks"] + +[workspace.dependencies] +serde = { version = "1.0.203", features = ["serde_derive"] } +serde_json = "1.0.117" diff --git a/flake.nix b/flake.nix index d89e7e1..eb0da38 100644 --- a/flake.nix +++ b/flake.nix @@ -20,45 +20,31 @@ { nixpkgs-hammering = - let - rust-checks = prev.rustPlatform.buildRustPackage { - pname = "rust-checks"; - version = (prev.lib.importTOML ./rust-checks/Cargo.toml).package.version; - src = ./rust-checks; - cargoLock.lockFile = ./rust-checks/Cargo.lock; - }; + prev.rustPlatform.buildRustPackage { + pname = "nixpkgs-hammering"; + version = (prev.lib.importTOML ./Cargo.toml).package.version; + src = ./.; + + cargoLock.lockFile = ./Cargo.lock; - # Find all of the binaries installed by rust-checks. Note, if this changes - # in the future to use wrappers or something else that pollute the bin/ - # directory, this logic will have to grow. - rust-check-names = let - binContents = builtins.readDir "${rust-checks}/bin"; - in - prev.lib.mapAttrsToList (name: type: assert type == "regular"; name) binContents; - in - prev.runCommand "nixpkgs-hammering" { - buildInputs = with prev; [ - python3 - makeWrapper - ]; + nativeBuildInputs = with prev; [ + makeWrapper + ]; + + passthru = { + exePath = "/bin/nixpkgs-hammer"; + }; - passthru = { - inherit rust-checks; - exePath = "/bin/nixpkgs-hammer"; - }; - } '' - install -D ${./tools/nixpkgs-hammer} $out/bin/nixpkgs-hammer - patchShebangs $out/bin/nixpkgs-hammer + postInstall = '' + datadir="$out/share/nixpkgs-hammering" + mkdir -p "$datadir" wrapProgram "$out/bin/nixpkgs-hammer" \ - --prefix PATH ":" ${prev.lib.makeBinPath [ - prev.nix - rust-checks - ]} \ - --set AST_CHECK_NAMES ${prev.lib.concatStringsSep ":" rust-check-names} - cp -r ${./overlays} $out/overlays - cp -r ${./lib} $out/lib + --set OVERLAYS_DIR "$datadir/overlays" + cp -r ${./overlays} "$datadir/overlays" + cp -r ${./lib} "$datadir/lib" ''; + }; }; }; } // utils.lib.eachDefaultSystem (system: let @@ -86,6 +72,7 @@ rustc cargo rust-analyzer + rustfmt ]; }; }; diff --git a/rust-checks/Cargo.lock b/rust-checks/Cargo.lock deleted file mode 100644 index 19b1014..0000000 --- a/rust-checks/Cargo.lock +++ /dev/null @@ -1,278 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -[[package]] -name = "aho-corasick" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7404febffaa47dac81aa44dba71523c9d069b1bdc50a77db41195149e17f68e5" -dependencies = [ - "memchr", -] - -[[package]] -name = "autocfg" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" - -[[package]] -name = "cbitset" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29b6ad25ae296159fb0da12b970b2fe179b234584d7cd294c891e2bbb284466b" -dependencies = [ - "num-traits", -] - -[[package]] -name = "codespan" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "991d34632921756cbe9e3e1736b2e1f12f16166a9c9cd91979d96d4c0a086b63" -dependencies = [ - "codespan-reporting", -] - -[[package]] -name = "codespan-reporting" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6ce42b8998a383572e0a802d859b1f00c79b7b7474e62fff88ee5c2845d9c13" -dependencies = [ - "termcolor", - "unicode-width", -] - -[[package]] -name = "itoa" -version = "0.4.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd25036021b0de88a0aff6b850051563c6516d0bf53f8638938edbb9de732736" - -[[package]] -name = "memchr" -version = "2.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ee1c47aaa256ecabcaea351eae4a9b01ef39ed810004e298d2511ed284b1525" - -[[package]] -name = "nixpkgs-hammering-ast-checks" -version = "0.0.0" -dependencies = [ - "codespan", - "regex", - "rnix", - "rowan", - "serde", - "serde_json", -] - -[[package]] -name = "num-traits" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290" -dependencies = [ - "autocfg", -] - -[[package]] -name = "once_cell" -version = "1.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13bd41f508810a131401606d54ac32a467c97172d74ba7662562ebba5ad07fa0" - -[[package]] -name = "proc-macro2" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e0704ee1a7e00d7bb417d0770ea303c1bccbabf0ef1667dae92b5967f5f8a71" -dependencies = [ - "unicode-xid", -] - -[[package]] -name = "quote" -version = "1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "991431c3519a3f36861882da93630ce66b52918dcf1b8e2fd66b397fc96f28df" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "regex" -version = "1.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9251239e129e16308e70d853559389de218ac275b515068abc96829d05b948a" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", - "thread_local", -] - -[[package]] -name = "regex-syntax" -version = "0.6.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5eb417147ba9860a96cfe72a0b93bf88fee1744b5636ec99ab20c1aa9376581" - -[[package]] -name = "rnix" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbbea4c714e5bbf462fa4316ddf45875d8f0e28e5db81050b5f9ce99746c6863" -dependencies = [ - "cbitset", - "rowan", -] - -[[package]] -name = "rowan" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ea7cadf87a9d8432e85cb4eb86bd2e765ace60c24ef86e79084dcae5d1c5a19" -dependencies = [ - "rustc-hash", - "serde", - "smol_str", - "text_unit", - "thin-dst", -] - -[[package]] -name = "rustc-hash" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" - -[[package]] -name = "ryu" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e" - -[[package]] -name = "serde" -version = "1.0.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6159e3c76cab06f6bc466244d43b35e77e9500cd685da87620addadc2a4c40b1" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.121" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3fcab8778dc651bc65cfab2e4eb64996f3c912b74002fb379c94517e1f27c46" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.61" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fceb2595057b6891a4ee808f70054bd2d12f0e97f1cbb78689b59f676df325a" -dependencies = [ - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "smol_str" -version = "0.1.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ca0f7ce3a29234210f0f4f0b56f8be2e722488b95cb522077943212da3b32eb" - -[[package]] -name = "syn" -version = "1.0.59" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07cb8b1b4ebf86a89ee88cbd201b022b94138c623644d035185c84d3f41b7e66" -dependencies = [ - "proc-macro2", - "quote", - "unicode-xid", -] - -[[package]] -name = "termcolor" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dfed899f0eb03f32ee8c6a0aabdb8a7949659e3466561fc0adf54e26d88c5f4" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "text_unit" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20431e104bfecc1a40872578dbc390e10290a0e9c35fffe3ce6f73c15a9dbfc2" -dependencies = [ - "serde", -] - -[[package]] -name = "thin-dst" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db3c46be180f1af9673ebb27bc1235396f61ef6965b3fe0dbb2e624deb604f0e" - -[[package]] -name = "thread_local" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8018d24e04c95ac8790716a5987d0fec4f8b27249ffa0f7d33f1369bdfb88cbd" -dependencies = [ - "once_cell", -] - -[[package]] -name = "unicode-width" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9337591893a19b88d8d87f2cec1e73fad5cdfd10e5a6f349f498ad6ea2ffb1e3" - -[[package]] -name = "unicode-xid" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7fe0bb3479651439c9112f72b6c505038574c9fbb575ed1bf3b797fa39dd564" - -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - -[[package]] -name = "winapi-util" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" -dependencies = [ - "winapi", -] - -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" diff --git a/rust-checks/Cargo.toml b/rust-checks/Cargo.toml index db85978..b9a6ebb 100644 --- a/rust-checks/Cargo.toml +++ b/rust-checks/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "nixpkgs-hammering-ast-checks" +name = "rust-checks" version = "0.0.0" authors = ["Robert T. McGibbon "] edition = "2021" @@ -7,7 +7,7 @@ edition = "2021" [dependencies] codespan = "0.11" regex = "1" -rnix = "0.8" -rowan = { version = "0.9", features = [ "serde1" ] } -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" +rnix = "0.11" +rowan = { version = "0.15", features = [ "serde1" ] } +serde = { workspace = true } +serde_json = { workspace = true } diff --git a/rust-checks/src/analysis.rs b/rust-checks/src/analysis.rs index 8b0f266..d5b69cf 100644 --- a/rust-checks/src/analysis.rs +++ b/rust-checks/src/analysis.rs @@ -1,18 +1,24 @@ -use crate::common_structs::{Attr, NixpkgsHammerMessage}; +use crate::common_structs::{CheckedAttr, NixpkgsHammerMessage}; use codespan::{FileId, Files}; -use rnix::{types::*, SyntaxNode}; -use std::io::BufReader; -use std::path::Path; -use std::process::{ChildStdout, Command, Stdio}; -use std::{collections::HashMap, error::Error, fs}; +use rnix::{Root, SyntaxNode}; +use rowan::ast::AstNode as _; +use std::{ + collections::HashMap, + error::Error, + fs, + io::BufReader, + path::Path, + process::{ChildStdout, Command, Stdio}, +}; pub type Report = Vec; pub type NixFileAnalyzer = fn(&Files, FileId) -> Result>; -pub type LogFileAnalyzer = fn(BufReader, &Attr) -> Result>; +pub type LogFileAnalyzer = + fn(BufReader, &CheckedAttr) -> Result>; /// Runs given analyzer the nix file for each attr pub fn analyze_nix_files( - attrs: Vec, + attrs: Vec, analyzer: NixFileAnalyzer, ) -> Result> { let mut report: HashMap = HashMap::new(); @@ -29,7 +35,7 @@ pub fn analyze_nix_files( /// Runs given analyzer on log file for each attr, if exists pub fn analyze_log_files( - attrs: Vec, + attrs: Vec, analyzer: LogFileAnalyzer, ) -> Result> { let mut report: HashMap = HashMap::new(); @@ -59,23 +65,23 @@ pub fn analyze_log_files( /// Parses a Nix file and returns a root AST node. pub fn find_root(files: &Files, file_id: FileId) -> Result { - let ast = rnix::parse(files.source(file_id)) - .as_result() - .map_err(|_| { - format!( - "Unable to parse {} as a nix file", - files - .name(file_id) - .to_str() - .unwrap_or("unprintable file, encoding error") - ) - })?; + let root = Root::parse(files.source(file_id)).ok().map_err(|_| { + format!( + "Unable to parse {} as a nix file", + files + .name(file_id) + .to_str() + .unwrap_or("unprintable file, encoding error") + ) + })?; - ast.root().inner().ok_or(format!( + let expr = root.expr().ok_or(format!( "No elements in the AST in path {}", files .name(file_id) .to_str() .unwrap_or("unprintable file, encoding error") - )) + ))?; + + Ok(expr.syntax().clone()) } diff --git a/rust-checks/src/bin/missing-patch-comment.rs b/rust-checks/src/bin/missing-patch-comment.rs index 486994a..f65e6f5 100644 --- a/rust-checks/src/bin/missing-patch-comment.rs +++ b/rust-checks/src/bin/missing-patch-comment.rs @@ -1,97 +1,8 @@ -use codespan::{FileId, Files}; -use nixpkgs_hammering_ast_checks::analysis::*; -use nixpkgs_hammering_ast_checks::comment_finders::{find_comment_above, find_comment_within, find_comment_after}; -use nixpkgs_hammering_ast_checks::common_structs::{NixpkgsHammerMessage, SourceLocation, Attr}; -use nixpkgs_hammering_ast_checks::tree_utils::{parents, walk_keyvalues_filter_key, walk_kind}; -use rnix::{types::*, SyntaxKind::*}; -use std::{io, error::Error}; +use rust_checks::{checks::missing_patch_comment, common_structs::CheckedAttr}; +use std::{error::Error, io}; fn main() -> Result<(), Box> { - let attrs: Vec = serde_json::from_reader(io::stdin())?; - println!("{}", analyze_nix_files(attrs, analyze_single_file)?); + let attrs: Vec = serde_json::from_reader(io::stdin())?; + println!("{}", missing_patch_comment(attrs)?); Ok(()) } - -fn analyze_single_file(files: &Files, file_id: FileId) -> Result> { - let root = find_root(files, file_id)?; - let mut report: Report = vec![]; - - // Find all “patches” attrset attributes, that - // *do not* have a comment directly above them. - let patches_without_top_comment = walk_keyvalues_filter_key(&root, "patches").filter(|kv| { - // Look for a comment directly above the `patches = …` line - // (Interpreting that as a comment which applies to all patches.) - find_comment_above(kv.node()).is_none() - }); - - // For each list of patches without a top comment, - // produce a report for each patch that is missing a per-patch comment. - for kv in patches_without_top_comment { - let value = kv - .value() - .ok_or("Internal logic error: Unable to extract value from key/value pair")?; - let contained_nonnested_lists = walk_kind(&value, NODE_LIST).filter(|elem| { - // As we’re walking down the tree looking for list nodes under `kv`, we don’t - // want to walk too deep. We’re looking for lists, but we don’t want to find - // any lists that are inside other lists, since those are likely not patches - // (they’re probably arguments to `fetchpatch` like `excludes`) - - // So we’ll filter the lists returned by walk_kind (which is doing a full - // tree traversal) by counting the number of lists that occur between this - // node `elem` and `kv`, and checking for there to be only 1. - - // N.b.: there’s a more efficient way to do this, but it wouldn’t allow us - // to re-use walk_kind and implementing our own tree traversal in rust is not - // worth it -- speed is not an issue - parents(elem.clone().into_node().unwrap()) - .take_while(|elem| elem.text_range() != kv.node().text_range()) - .filter(|elem| elem.kind() == NODE_LIST) - .count() - == 1 - }); - for elem in contained_nonnested_lists { - let patch_list = List::cast(elem.into_node().ok_or( - "Internal logic error: Unable to cast element with kind NODE_LIST to into a Node", - )?) - .ok_or( - "Internal logic error: Unable to cast from a node with kind NODE_LIST to a List", - )?; - report.extend(process_patch_list(patch_list, files, file_id)?); - } - } - - Ok(report) -} - -fn process_patch_list( - patchlist: List, - files: &Files, - file_id: FileId, -) -> Result> { - let mut report: Report = vec![]; - - // For each element in the list of patches, look for - // a comment directly above the element or, if we don’t see - // one of those, look for a comment within AST of the element. - for item in patchlist.items() { - let has_comment_above = find_comment_above(&item).is_some(); - let has_comment_within = find_comment_within(&item).is_some(); - let has_comment_after = find_comment_after(&item).is_some(); - let has_comment = has_comment_above || has_comment_within || has_comment_after; - - if !has_comment { - let start = item.text_range().start().to_usize() as u32; - - report.push(NixpkgsHammerMessage { - msg: - "Consider adding a comment explaining the purpose of this patch on the line preceeding." - .to_string(), - name: "missing-patch-comment", - locations: vec![SourceLocation::from_byte_index(files, file_id, start)?], - link: true, - }); - } - } - - Ok(report) -} diff --git a/rust-checks/src/bin/no-python-tests.rs b/rust-checks/src/bin/no-python-tests.rs index dd6b303..3913111 100644 --- a/rust-checks/src/bin/no-python-tests.rs +++ b/rust-checks/src/bin/no-python-tests.rs @@ -1,50 +1,8 @@ -use nixpkgs_hammering_ast_checks::analysis::*; -use nixpkgs_hammering_ast_checks::common_structs::*; -use regex::Regex; -use std::error::Error; -use std::io::{self, BufRead, BufReader}; -use std::process::ChildStdout; +use rust_checks::{checks::no_python_tests, common_structs::CheckedAttr}; +use std::{error::Error, io}; fn main() -> Result<(), Box> { - let attrs: Vec = serde_json::from_reader(io::stdin())?; - println!("{}", analyze_log_files(attrs, analyze_single_file)?); + let attrs: Vec = serde_json::from_reader(io::stdin())?; + println!("{}", no_python_tests(attrs)?); Ok(()) } - -fn analyze_single_file(log: BufReader, attr: &Attr) -> Result> { - // Looking for two kinds of messages in the log that indicate no tests were run. The - // first is from `setuptoolsCheckPhase`, and looks like - - // running build_ext - // ---------------------------------------------------------------------- - // Ran 0 tests in 0.000s - // OK - // Finished executing setuptoolsCheckPhase - - // The second is from pytest (either invoked manually or from pytestCheckHook) - // and looks like - // ============================= test session starts ============================== - // platform linux -- Python 3.8.8, pytest-6.2.2, py-1.9.0, pluggy-0.13.1 - // rootdir: /build/humanize-3.1.0, configfile: tox.ini - // collected 0 items - // ============================ no tests ran in 0.00s ============================= - let re = Regex::new(r"(Ran 0 tests|no tests ran\x1b\[0m\x1b\[33m) in \d+.\d+s").unwrap(); - let ansi_escape = Regex::new(r"\x1b\[0m\x1b\[33m").unwrap(); - - let report = log - .lines() - .filter_map(|line| line.ok()) - .filter_map(|s| re.find(&s).and_then(|m| Some(m.as_str().to_string()))) - .map(|m| NixpkgsHammerMessage { - msg: format!( - "Test runner could not discover any test cases: ‘{}’", - ansi_escape.replace(&m, "") - ), - name: "no-python-tests", - locations: attr.location.iter().map(|x| x.clone()).collect(), - link: true, - }) - .collect(); - - Ok(report) -} diff --git a/rust-checks/src/bin/no-uri-literals.rs b/rust-checks/src/bin/no-uri-literals.rs index b3612f4..f689394 100644 --- a/rust-checks/src/bin/no-uri-literals.rs +++ b/rust-checks/src/bin/no-uri-literals.rs @@ -1,34 +1,8 @@ -use codespan::{FileId, Files}; -use nixpkgs_hammering_ast_checks::analysis::*; -use nixpkgs_hammering_ast_checks::common_structs::{Attr, NixpkgsHammerMessage, SourceLocation}; -use rnix::SyntaxKind::*; -use std::{io, error::Error}; -use nixpkgs_hammering_ast_checks::tree_utils::walk_kind; +use rust_checks::{checks::no_uri_literals, common_structs::CheckedAttr}; +use std::{error::Error, io}; fn main() -> Result<(), Box> { - let attrs: Vec = serde_json::from_reader(io::stdin())?; - println!("{}", analyze_nix_files(attrs, analyze_single_file)?); + let attrs: Vec = serde_json::from_reader(io::stdin())?; + println!("{}", no_uri_literals(attrs)?); Ok(()) } - -fn analyze_single_file( - files: &Files, - file_id: FileId, -) -> Result> { - let root = find_root(files, file_id)?; - let mut report: Report = vec![]; - - // Find all URI literals and report them. - for uri in walk_kind(&root, TOKEN_URI) { - let start = uri.text_range().start().to_usize() as u32; - - report.push(NixpkgsHammerMessage { - msg: "URI literals are deprecated.".to_string(), - name: "no-uri-literals", - locations: vec![SourceLocation::from_byte_index(files, file_id, start)?], - link: true, - }); - } - - Ok(report) -} diff --git a/rust-checks/src/bin/stale-substitute.rs b/rust-checks/src/bin/stale-substitute.rs index c002a75..11b2ba7 100644 --- a/rust-checks/src/bin/stale-substitute.rs +++ b/rust-checks/src/bin/stale-substitute.rs @@ -1,33 +1,8 @@ -use nixpkgs_hammering_ast_checks::analysis::*; -use nixpkgs_hammering_ast_checks::common_structs::*; -use regex::Regex; -use std::error::Error; -use std::io::{self, BufRead, BufReader}; -use std::process::ChildStdout; +use rust_checks::{checks::stale_substitute, common_structs::CheckedAttr}; +use std::{error::Error, io}; fn main() -> Result<(), Box> { - let attrs: Vec = serde_json::from_reader(io::stdin())?; - println!("{}", analyze_log_files(attrs, analyze_single_file)?); + let attrs: Vec = serde_json::from_reader(io::stdin())?; + println!("{}", stale_substitute(attrs)?); Ok(()) } - -fn analyze_single_file(log: BufReader, attr: &Attr) -> Result> { - let re = Regex::new( - r"substituteStream\(\): WARNING: pattern (.*?) doesn't match anything in file '(.*?)'", - ) - .unwrap(); - - let report = log - .lines() - .filter_map(|line| line.ok()) - .filter_map(|s| re.find(&s).and_then(|m| Some(m.as_str().to_string()))) - .map(|m| NixpkgsHammerMessage { - msg: format!("Stale substituteInPlace detected.\n{}", m), - name: "stale-substitute", - locations: attr.location.iter().map(|x| x.clone()).collect(), - link: true, - }) - .collect(); - - Ok(report) -} diff --git a/rust-checks/src/bin/unused-argument.rs b/rust-checks/src/bin/unused-argument.rs index 82d5dc4..dcf139f 100644 --- a/rust-checks/src/bin/unused-argument.rs +++ b/rust-checks/src/bin/unused-argument.rs @@ -1,109 +1,8 @@ -use codespan::{FileId, Files}; -use nixpkgs_hammering_ast_checks::analysis::*; -use nixpkgs_hammering_ast_checks::common_structs::*; -use nixpkgs_hammering_ast_checks::tree_utils::walk_kind; -use rnix::SyntaxKind::*; -use rnix::{types::*, SyntaxNode}; -use std::{io, error::Error}; +use rust_checks::{checks::unused_argument, common_structs::CheckedAttr}; +use std::{error::Error, io}; fn main() -> Result<(), Box> { - let attrs: Vec = serde_json::from_reader(io::stdin())?; - println!("{}", analyze_nix_files(attrs, analyze_single_file)?); + let attrs: Vec = serde_json::from_reader(io::stdin())?; + println!("{}", unused_argument(attrs)?); Ok(()) } - -fn analyze_single_file(files: &Files, file_id: FileId) -> Result> { - let root = find_root(files, file_id)?; - let mut report: Report = vec![]; - - // This check looks for unused variables by walking the AST. It has a very - // simple implementation: for each function that takes its arguments using - // a pattern contract, we record the names of the variables. Then, we walk through - // the body of the function and look for those names being used as identifiers. If - // we don't see them, we call it an unused variable. - - // This design is simple and I don't believe that it produces any false positives, - // which would be highly undesirable. It does, however, produce some false negatives. - // Because we're not tracking all the intermediate scopes between the declaration of the - // formal parameter and its use in the function body, it's possible for inner functions, - // with statements, or let blocks that cause variables in the outer scope to be shadowed - // to make this check _think_ that a variable is used when it really wasn't. - - // Fixing this behavior could be done by following the pseudocode in - // https://github.com/jtojnar/nixpkgs-hammering/pull/32#issuecomment-776936922 - // but hasn't been done yet because we don't think rare false negatives are a high - // priority. - - for lambda in walk_kind(&root, NODE_LAMBDA) - .filter_map(|elem| elem.into_node().and_then(Lambda::cast)) - .filter(|lambda| lambda.arg().and_then(Pattern::cast).is_some()) - { - let body = lambda - .clone() - .body() - .ok_or("Unable to extract function body")?; - - // Extract the formal parameters from pattern-type functions, and don't - // extract anything from single-argument functions because they often - // need to have unused arguments for overlay-type constructs. - let pattern = lambda.arg().and_then(Pattern::cast).unwrap(); - - let identifiers_in_body: Vec = walk_kind(&body, NODE_IDENT) - .filter_map(|elem| elem.into_node()) - .filter_map(Ident::cast) - // Filter out identifiers that are acting as keys in an attrset - // i.e a construct like ```{ - // x = 1; - // }``` - // does not mean that `x` is acting as a usage of the identifier - // `x` for purposes of detecting unused variables - .filter(|ident| !ident_is_attrset_key(ident.node())) - .chain( - // Also collect any identifiers that appear in the default - // definitions of arguments to the function, such as `pkgs` in - // - // { pkgs # might look unused, but is not - // , foobarbazqux ? pkgs.hello - // }: … - pattern - .entries() - .filter_map(|entry| entry.default()) - .flat_map(|e| { - walk_kind(&e, NODE_IDENT) - .filter_map(|el| el.into_node()) - .filter_map(Ident::cast) - }), - ) - .collect(); - - let unused_formal_parameters = pattern - .entries() - .filter_map(|entry| entry.name()) - // Filter out formal parameters that are used as identifiers - // in the function body - .filter(|formal| { - !identifiers_in_body - .iter() - .any(|ident| ident.as_str() == formal.as_str()) - }); - - for unused in unused_formal_parameters { - let start = unused.node().text_range().start().to_usize() as u32; - report.push(NixpkgsHammerMessage { - msg: format!("Unused argument: `{}`.", unused.node()), - name: "unused-argument", - locations: vec![SourceLocation::from_byte_index(files, file_id, start)?], - link: false, - }); - } - } - - Ok(report) -} - -fn ident_is_attrset_key(node: &SyntaxNode) -> bool { - match node.parent() { - Some(p) if p.kind() == NODE_KEY => true, - _ => false, - } -} diff --git a/rust-checks/src/checks/missing_patch_comment.rs b/rust-checks/src/checks/missing_patch_comment.rs new file mode 100644 index 0000000..e590e27 --- /dev/null +++ b/rust-checks/src/checks/missing_patch_comment.rs @@ -0,0 +1,100 @@ +use crate::{ + analysis::*, + comment_finders::{find_comment_above, find_comment_after, find_comment_within}, + common_structs::{CheckedAttr, NixpkgsHammerMessage, SourceLocation}, + tree_utils::{parents, walk_attrpath_values_filter_attrpath, walk_kind}, +}; +use codespan::{FileId, Files}; +use rnix::{ast::*, SyntaxKind::*}; +use rowan::ast::AstNode as _; +use std::error::Error; + +pub fn run(attrs: Vec) -> Result> { + analyze_nix_files(attrs, analyze_single_file) +} + +fn analyze_single_file(files: &Files, file_id: FileId) -> Result> { + let root = find_root(files, file_id)?; + let mut report: Report = vec![]; + + // Find all “patches” attrset attributes, that + // *do not* have a comment directly above them. + let patches_without_top_comment = walk_attrpath_values_filter_attrpath(&root, "patches") + .filter(|pair| { + // Look for a comment directly above the `patches = …` line + // (Interpreting that as a comment which applies to all patches.) + find_comment_above(pair.syntax()).is_none() + }); + + // For each list of patches without a top comment, + // produce a report for each patch that is missing a per-patch comment. + for pair in patches_without_top_comment { + let value = pair + .value() + .ok_or("Internal logic error: Unable to extract value from key/value pair")?; + let contained_nonnested_lists = walk_kind(&value.syntax(), NODE_LIST).filter(|elem| { + // As we’re walking down the tree looking for list nodes under `pair`, we don’t + // want to walk too deep. We’re looking for lists, but we don’t want to find + // any lists that are inside other lists, since those are likely not patches + // (they’re probably arguments to `fetchpatch` like `excludes`) + + // So we’ll filter the lists returned by walk_kind (which is doing a full + // tree traversal) by counting the number of lists that occur between this + // node `elem` and `pair`, and checking for there to be only 1. + + // N.b.: there’s a more efficient way to do this, but it wouldn’t allow us + // to re-use walk_kind and implementing our own tree traversal in rust is not + // worth it -- speed is not an issue + parents(elem.clone().into_node().unwrap()) + .take_while(|elem| elem.text_range() != pair.syntax().text_range()) + .filter(|elem| elem.kind() == NODE_LIST) + .count() + == 1 + }); + for elem in contained_nonnested_lists { + let patch_list = List::cast(elem.into_node().ok_or( + "Internal logic error: Unable to cast element with kind NODE_LIST to into a Node", + )?) + .ok_or( + "Internal logic error: Unable to cast from a node with kind NODE_LIST to a List", + )?; + report.extend(process_patch_list(patch_list, files, file_id)?); + } + } + + Ok(report) +} + +fn process_patch_list( + patchlist: List, + files: &Files, + file_id: FileId, +) -> Result> { + let mut report: Report = vec![]; + + // For each element in the list of patches, look for + // a comment directly above the element or, if we don’t see + // one of those, look for a comment within AST of the element. + for item in patchlist.items() { + let item = item.syntax(); + let has_comment_above = find_comment_above(&item).is_some(); + let has_comment_within = find_comment_within(&item).is_some(); + let has_comment_after = find_comment_after(&item).is_some(); + let has_comment = has_comment_above || has_comment_within || has_comment_after; + + if !has_comment { + let start: u32 = item.text_range().start().into(); + + report.push(NixpkgsHammerMessage { + msg: + "Consider adding a comment explaining the purpose of this patch on the line preceeding." + .to_string(), + name: "missing-patch-comment", + locations: vec![SourceLocation::from_byte_index(files, file_id, start)?], + link: true, + }); + } + } + + Ok(report) +} diff --git a/rust-checks/src/checks/mod.rs b/rust-checks/src/checks/mod.rs new file mode 100644 index 0000000..0db651a --- /dev/null +++ b/rust-checks/src/checks/mod.rs @@ -0,0 +1,25 @@ +mod missing_patch_comment; +mod no_python_tests; +mod no_uri_literals; +mod stale_substitute; +mod unused_argument; + +use std::error::Error; + +pub use missing_patch_comment::run as missing_patch_comment; +pub use no_python_tests::run as no_python_tests; +pub use no_uri_literals::run as no_uri_literals; +pub use stale_substitute::run as stale_substitute; +pub use unused_argument::run as unused_argument; + +use crate::common_structs::CheckedAttr; + +pub type Check = fn(Vec) -> Result>; + +pub const ALL: [(&'static str, Check); 5] = [ + ("missing-patch-comment", missing_patch_comment), + ("no-python-tests", no_python_tests), + ("no-uri-literals", no_uri_literals), + ("stale-substitute", stale_substitute), + ("unused-argument", unused_argument), +]; diff --git a/rust-checks/src/checks/no_python_tests.rs b/rust-checks/src/checks/no_python_tests.rs new file mode 100644 index 0000000..a7068d1 --- /dev/null +++ b/rust-checks/src/checks/no_python_tests.rs @@ -0,0 +1,52 @@ +use crate::{analysis::*, common_structs::*}; +use regex::Regex; +use std::{ + error::Error, + io::{BufRead, BufReader}, + process::ChildStdout, +}; + +pub fn run(attrs: Vec) -> Result> { + analyze_log_files(attrs, analyze_single_file) +} + +fn analyze_single_file( + log: BufReader, + attr: &CheckedAttr, +) -> Result> { + // Looking for two kinds of messages in the log that indicate no tests were run. The + // first is from `setuptoolsCheckPhase`, and looks like + + // running build_ext + // ---------------------------------------------------------------------- + // Ran 0 tests in 0.000s + // OK + // Finished executing setuptoolsCheckPhase + + // The second is from pytest (either invoked manually or from pytestCheckHook) + // and looks like + // ============================= test session starts ============================== + // platform linux -- Python 3.8.8, pytest-6.2.2, py-1.9.0, pluggy-0.13.1 + // rootdir: /build/humanize-3.1.0, configfile: tox.ini + // collected 0 items + // ============================ no tests ran in 0.00s ============================= + let re = Regex::new(r"(Ran 0 tests|no tests ran\x1b\[0m\x1b\[33m) in \d+.\d+s").unwrap(); + let ansi_escape = Regex::new(r"\x1b\[0m\x1b\[33m").unwrap(); + + let report = log + .lines() + .filter_map(|line| line.ok()) + .filter_map(|s| re.find(&s).and_then(|m| Some(m.as_str().to_string()))) + .map(|m| NixpkgsHammerMessage { + msg: format!( + "Test runner could not discover any test cases: ‘{}’", + ansi_escape.replace(&m, "") + ), + name: "no-python-tests", + locations: attr.location.iter().map(|x| x.clone()).collect(), + link: true, + }) + .collect(); + + Ok(report) +} diff --git a/rust-checks/src/checks/no_uri_literals.rs b/rust-checks/src/checks/no_uri_literals.rs new file mode 100644 index 0000000..3c0ab08 --- /dev/null +++ b/rust-checks/src/checks/no_uri_literals.rs @@ -0,0 +1,27 @@ +use crate::{analysis::*, common_structs::*, tree_utils::walk_kind}; +use codespan::{FileId, Files}; +use rnix::SyntaxKind::*; +use std::error::Error; + +pub fn run(attrs: Vec) -> Result> { + analyze_nix_files(attrs, analyze_single_file) +} + +fn analyze_single_file(files: &Files, file_id: FileId) -> Result> { + let root = find_root(files, file_id)?; + let mut report: Report = vec![]; + + // Find all URI literals and report them. + for uri in walk_kind(&root, TOKEN_URI) { + let start: u32 = uri.text_range().start().into(); + + report.push(NixpkgsHammerMessage { + msg: "URI literals are deprecated.".to_string(), + name: "no-uri-literals", + locations: vec![SourceLocation::from_byte_index(files, file_id, start)?], + link: true, + }); + } + + Ok(report) +} diff --git a/rust-checks/src/checks/stale_substitute.rs b/rust-checks/src/checks/stale_substitute.rs new file mode 100644 index 0000000..b6c6059 --- /dev/null +++ b/rust-checks/src/checks/stale_substitute.rs @@ -0,0 +1,35 @@ +use crate::{analysis::*, common_structs::*}; +use regex::Regex; +use std::{ + error::Error, + io::{BufRead, BufReader}, + process::ChildStdout, +}; + +pub fn run(attrs: Vec) -> Result> { + analyze_log_files(attrs, analyze_single_file) +} + +fn analyze_single_file( + log: BufReader, + attr: &CheckedAttr, +) -> Result> { + let re = Regex::new( + r"substituteStream\(\): WARNING: pattern (.*?) doesn't match anything in file '(.*?)'", + ) + .unwrap(); + + let report = log + .lines() + .filter_map(|line| line.ok()) + .filter_map(|s| re.find(&s).and_then(|m| Some(m.as_str().to_string()))) + .map(|m| NixpkgsHammerMessage { + msg: format!("Stale substituteInPlace detected.\n{}", m), + name: "stale-substitute", + locations: attr.location.iter().map(|x| x.clone()).collect(), + link: true, + }) + .collect(); + + Ok(report) +} diff --git a/rust-checks/src/checks/unused_argument.rs b/rust-checks/src/checks/unused_argument.rs new file mode 100644 index 0000000..4360c44 --- /dev/null +++ b/rust-checks/src/checks/unused_argument.rs @@ -0,0 +1,114 @@ +use crate::{analysis::*, common_structs::*, tree_utils::walk_kind}; +use codespan::{FileId, Files}; +use rnix::{ast::*, SyntaxKind::*, SyntaxNode}; +use rowan::ast::AstNode as _; +use std::error::Error; + +pub fn run(attrs: Vec) -> Result> { + analyze_nix_files(attrs, analyze_single_file) +} + +fn analyze_single_file(files: &Files, file_id: FileId) -> Result> { + let root = find_root(files, file_id)?; + let mut report: Report = vec![]; + + // This check looks for unused variables by walking the AST. It has a very + // simple implementation: for each function that takes its arguments using + // a pattern contract, we record the names of the variables. Then, we walk through + // the body of the function and look for those names being used as identifiers. If + // we don't see them, we call it an unused variable. + + // This design is simple and I don't believe that it produces any false positives, + // which would be highly undesirable. It does, however, produce some false negatives. + // Because we're not tracking all the intermediate scopes between the declaration of the + // formal parameter and its use in the function body, it's possible for inner functions, + // with statements, or let blocks that cause variables in the outer scope to be shadowed + // to make this check _think_ that a variable is used when it really wasn't. + + // Fixing this behavior could be done by following the pseudocode in + // https://github.com/jtojnar/nixpkgs-hammering/pull/32#issuecomment-776936922 + // but hasn't been done yet because we don't think rare false negatives are a high + // priority. + + for lambda in walk_kind(&root, NODE_LAMBDA) + .filter_map(|elem| elem.into_node().and_then(Lambda::cast)) + .filter(|lambda| { + lambda + .param() + .and_then(|param| Pattern::cast(param.syntax().clone())) + .is_some() + }) + { + let body = lambda + .clone() + .body() + .ok_or("Unable to extract function body")?; + + // Extract the formal parameters from pattern-type functions, and don't + // extract anything from single-argument functions because they often + // need to have unused arguments for overlay-type constructs. + let pattern = lambda + .param() + .and_then(|param| Pattern::cast(param.syntax().clone())) + .unwrap(); + + let identifiers_in_body: Vec = walk_kind(&body.syntax(), NODE_IDENT) + .filter_map(|elem| elem.into_node()) + .filter_map(Ident::cast) + // Filter out identifiers that are acting as keys in an attrset + // i.e a construct like ```{ + // x = 1; + // }``` + // does not mean that `x` is acting as a usage of the identifier + // `x` for purposes of detecting unused variables + .filter(|ident| !ident_is_attrset_key(ident.syntax())) + .chain( + // Also collect any identifiers that appear in the default + // definitions of arguments to the function, such as `pkgs` in + // + // { pkgs # might look unused, but is not + // , foobarbazqux ? pkgs.hello + // }: … + pattern + .pat_entries() + .filter_map(|entry| entry.default()) + .flat_map(|e| { + walk_kind(&e.syntax(), NODE_IDENT) + .filter_map(|el| el.into_node()) + .filter_map(Ident::cast) + }), + ) + .collect(); + + let unused_formal_parameters = pattern + .pat_entries() + .filter_map(|entry| entry.ident()) + // Filter out formal parameters that are used as identifiers + // in the function body + .filter(|formal| { + let formal = formal.syntax(); + !identifiers_in_body + .iter() + .any(|ident| ident.syntax().text() == formal.text()) + }); + + for unused in unused_formal_parameters { + let start: u32 = unused.syntax().text_range().start().into(); + report.push(NixpkgsHammerMessage { + msg: format!("Unused argument: `{}`.", unused.syntax()), + name: "unused-argument", + locations: vec![SourceLocation::from_byte_index(files, file_id, start)?], + link: false, + }); + } + } + + Ok(report) +} + +fn ident_is_attrset_key(node: &SyntaxNode) -> bool { + match node.parent() { + Some(p) if p.kind() == NODE_ATTRPATH => true, + _ => false, + } +} diff --git a/rust-checks/src/common_structs.rs b/rust-checks/src/common_structs.rs index 1a1f3cb..5bd06f0 100644 --- a/rust-checks/src/common_structs.rs +++ b/rust-checks/src/common_structs.rs @@ -2,8 +2,8 @@ use codespan::{ByteIndex, FileId, Files}; use serde::{Deserialize, Serialize}; use std::error::Error; -#[derive(Serialize, Deserialize, Debug)] -pub struct Attr { +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct CheckedAttr { pub name: String, pub location: Option, pub drv: Option, diff --git a/rust-checks/src/lib.rs b/rust-checks/src/lib.rs index 3a79b1a..d0908f4 100644 --- a/rust-checks/src/lib.rs +++ b/rust-checks/src/lib.rs @@ -1,4 +1,5 @@ pub mod analysis; -pub mod common_structs; +pub mod checks; pub mod comment_finders; +pub mod common_structs; pub mod tree_utils; diff --git a/rust-checks/src/tree_utils.rs b/rust-checks/src/tree_utils.rs index 98e4a7b..f936356 100644 --- a/rust-checks/src/tree_utils.rs +++ b/rust-checks/src/tree_utils.rs @@ -1,4 +1,5 @@ -use rnix::{types::*, SyntaxElement, SyntaxKind, SyntaxKind::*, SyntaxNode, WalkEvent}; +use rnix::{ast::*, SyntaxElement, SyntaxKind, SyntaxKind::*, SyntaxNode, WalkEvent}; +use rowan::ast::AstNode as _; use std::iter::{once, successors}; pub fn walk_kind(node: &SyntaxNode, kind: SyntaxKind) -> impl Iterator { @@ -10,19 +11,19 @@ pub fn walk_kind(node: &SyntaxNode, kind: SyntaxKind) -> impl Iterator( +pub fn walk_attrpath_values_filter_attrpath<'a>( node: &SyntaxNode, key: &'a str, -) -> impl Iterator + 'a { +) -> impl Iterator + 'a { // Iterates over all AST nodes under `node` that are of kind `NODE_KEY_VALUE`, // (which corresponds to a key-value pair inside a nix attrset) and yields // only the nodes where the key is equal to `key`. - walk_kind(node, NODE_KEY_VALUE) + walk_kind(node, NODE_ATTRPATH_VALUE) .filter_map(|element| element.into_node()) - .filter_map(|node| KeyValue::cast(node)) - .filter(move |kv| { - kv.key() - .map_or(false, |e| e.node().to_string() == key.to_string()) + .filter_map(|node| AttrpathValue::cast(node)) + .filter(move |pair| { + pair.attrpath() + .map_or(false, |e| e.syntax().to_string() == key.to_string()) }) } diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..ef0a13c --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,440 @@ +use indoc::formatdoc; +use model::{CheckedAttr, Report, Severity, SourceLocation}; +use rust_checks::checks::{self, Check}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::{HashMap, HashSet}, + fs::read_dir, + path::PathBuf, +}; +use utils::{indent, optional_env, run_command_with_input}; + +pub mod model; +pub mod utils; + +#[derive(Debug)] +pub struct Config { + pub nix_file: PathBuf, + pub show_trace: bool, + pub do_not_catch_errors: bool, + pub json: bool, + pub excluded_rules: HashSet, + pub attr_paths: Vec, +} + +fn get_overlays_dir() -> Result { + // Provided by wrapper. + if let Some(dir) = optional_env("OVERLAYS_DIR")? { + return Ok(PathBuf::from(dir)); + } + + // Running using `cargo run`. + if let Some(dir) = optional_env("CARGO_MANIFEST_DIR")? { + return Ok(PathBuf::from(&dir).join("overlays")); + } + + Err("Either ‘OVERLAYS_DIR’ or ‘CARGO_MANIFEST_DIR’ environment variable expected.".to_owned()) +} + +fn get_check_programs(excluded_rules: &HashSet) -> HashMap { + checks::ALL + .into_iter() + .map(|(name, check)| (name.to_string(), check)) + .filter(|(name, _)| !excluded_rules.contains(name)) + .collect() +} + +fn run_external_checks( + attrs: &[CheckedAttr], + excluded_rules: &HashSet, +) -> Result>, String> { + let rules = get_check_programs(excluded_rules); + if rules.is_empty() { + return Ok(HashMap::new()); + } + + let encoded_attrs = serde_json::to_string(attrs) + .map_err(|err| format!("Unable to serialize attrs: {}", err.to_string()))?; + let mut result = HashMap::new(); + + if !excluded_rules.contains("no-build-output") { + let names_without_outputs = attrs + .into_iter() + .filter(|a| a.output.as_ref().is_some_and(|o| o.exists())) + .map(|a| a.name.clone()); + for name in names_without_outputs { + result + .entry(name.clone()) + .or_insert_with(|| Vec::new()) + .push(Report { + name: "no-build-output".to_owned(), + msg: format!( + "‘{name}’ has not yet been built. \ + Checks that rely on the presence of build logs are skipped." + ), + link: false, + locations: vec![], + severity: Severity::Notice, + }) + } + } + + let mut rc_attrs = Vec::with_capacity(attrs.len()); + for attr in attrs { + rc_attrs.push(attr.clone().try_into()?); + } + for (name, check) in rules { + let json_text = check(rc_attrs.clone()).map_err(|err| { + eprintln!( + "{}", + red(&format!( + "Rule ‘{name}’ failed with input ‘{encoded_attrs}’.\ + This is a bug. Please file a ticket on GitHub." + )) + ); + + format!("Unable to execute rule ‘{name}’: {}", err.to_string()) + })?; + + if !json_text.is_empty() { + let results: HashMap> = + serde_json::from_str(&json_text).map_err(|err| { + format!( + "Unable to parse result of rule ‘{name}’: {}", + err.to_string() + ) + })?; + for (attr_name, reports) in results.into_iter() { + result + .entry(attr_name) + .or_insert_with(|| Vec::new()) + .extend(reports) + } + } + } + + Ok(result) +} + +fn concatenate_messages( + report_sources: &mut [HashMap>], +) -> HashMap> { + let mut result = HashMap::new(); + for m in report_sources.into_iter() { + for (attr_name, mut report) in m.drain() { + result + .entry(attr_name.clone()) + .or_insert_with(|| Vec::new()) + .append(&mut report); + } + } + result +} + +fn escape_attr(attr: &str) -> String { + attr.split(".") + .map(|section| format!("\"{section}\"")) + .collect::>() + .join(".") +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OverlayCheckResult { + /// Location in which the attr is defined, if exist + location: Option, + /// Path to the .drv file, if exists + drv_path: Option, + /// Path the the output of the drv in the nix store, if exists + output_path: Option, + report: Vec, +} + +fn nix_eval_json( + expr: String, + show_trace: bool, +) -> Result>, String> { + let mut args = vec!["--strict", "--json", "--eval", "-"]; + + if show_trace { + args.push("--show-trace"); + } + + let json_text = run_command_with_input("nix-instantiate", &args, &expr)?; + + let mut result = HashMap::new(); + let results: HashMap = + serde_json::from_str(&json_text).map_err(|err| { + format!( + "Unable to parse result of overlay checks: {} {json_text}", + err.to_string() + ) + })?; + for ( + name, + OverlayCheckResult { + location, + drv_path, + output_path, + mut report, + }, + ) in results.into_iter() + { + let attr = CheckedAttr { + name, + output: output_path, + drv: drv_path, + location, + }; + result + .entry(attr) + .or_insert_with(|| Vec::new()) + .append(&mut report); + } + + Ok(result) +} + +fn bold(msg: &str) -> String { + format!("{msg}") +} + +fn yellow(msg: &str) -> String { + format!("{msg}") +} + +fn red(msg: &str) -> String { + format!("{msg}") +} + +fn green(msg: &str) -> String { + format!("{msg}") +} + +fn render_code_location_ansi(loc: SourceLocation) -> String { + let Ok(opened_file) = std::fs::read_to_string(&loc.file) else { + return format!("Unable to load contents of file ‘{}’", loc.file.display()); + }; + let all_lines: Vec<_> = opened_file.split("\n").collect(); + let line_contents = all_lines[loc.line - 1]; + let line_no = loc.line.to_string(); + let line_spaces = " ".repeat(line_no.len()); + let column = loc.column.unwrap_or(1); + let pointer = " ".repeat(column - 1) + "^"; + let mut rendered_loc_parts = vec![loc.file.to_string_lossy().to_string(), line_no.to_string()]; + if !loc.column.is_none() { + let column_no = loc.line.to_string(); + rendered_loc_parts.push(column_no); + } + + formatdoc!( + "Near {}: + {line_spaces} | + {line_no} | {line_contents} + {line_spaces} | {pointer}", + rendered_loc_parts.join(":") + ) +} + +fn render_report_ansi(report: Report) -> String { + let color = if report.severity == Severity::Error { + red + } else { + yellow + }; + + let mut message_lines = vec![ + color(&format!("{}: {}", report.severity, report.name)), + report.msg, + ]; + message_lines.extend(report.locations.into_iter().map(render_code_location_ansi)); + + if report.link { + message_lines.push(format!( + "See: https://github.com/jtojnar/nixpkgs-hammering/blob/master/explanations/{}.md", + report.name, + )); + } + + return message_lines.join("\n"); +} + +pub fn hammer(config: Config) -> Result<(), String> { + let overlay_generators_path = get_overlays_dir()?; + + let try_eval = if config.do_not_catch_errors { + "(value: { inherit value; success = true; })" + } else { + "builtins.tryEval" + }; + + let mut attr_messages = vec![]; + + for attr in config.attr_paths { + attr_messages.push(formatdoc!( + r#" + "{attr}" = + let + result = {try_eval} pkgs.{} or null; + maybeReport = {try_eval} (result.value.__nixpkgs-hammering-state.reports or []); + in + if !(result.success && maybeReport.success) then + {{ + report = [ {{ + name = "EvalError"; + msg = "Cannot evaluate attribute ‘{attr}’ in ‘${{packageSet}}’."; + severity = "warning"; + link = false; + }} ]; + location = null; + outputPath = null; + drvPath = null; + }} + else if result.value == null then + {{ + report = [ {{ + name = "AttrPathNotFound"; + msg = "Packages in ‘${{packageSet}}’ do not contain ‘{attr}’ attribute."; + severity = "error"; + link = false; + }} ]; + location = null; + outputPath = null; + drvPath = null; + }} + else + {{ + report = if maybeReport.success then maybeReport.value else []; + location = + let + position = result.value.meta.position or null; + posSplit = builtins.split ":" result.value.meta.position; + in + if position == null then + null + else {{ + file = builtins.elemAt posSplit 0; + line = builtins.fromJSON (builtins.elemAt posSplit 2); + }}; + outputPath = result.value.outPath; + drvPath = result.value.drvPath; + }};"#, + escape_attr(&attr) + )); + } + + // Our overlays need to know the built attributes so that they can check only them. + // We do it by using functions that return overlays so we need to instantiate them. + let mut overlay_expressions = vec![]; + let generators = read_dir(&overlay_generators_path).map_err(|err| { + format!( + "Unable to list contents of overlays directory ‘{}’: {}", + overlay_generators_path.display(), + err.to_string() + ) + })?; + for overlay_generator in generators { + let overlay_generator = overlay_generator.map_err(|err| { + format!( + "Error reading overlays directory contents: {}", + err.to_string() + ) + })?; + if overlay_generator + .path() + .file_stem() + .map(|stem| { + config + .excluded_rules + .contains(&stem.to_string_lossy().to_string()) + }) + .unwrap_or(false) + { + continue; + } + + let overlay = overlay_generator.path(); + let overlay = overlay.to_str().ok_or_else(|| { + format!( + "Overlay path ‘{}’ not valid UTF-8 string.", + overlay.display() + ) + })?; + overlay_expressions.push(format!("(import {overlay})")); + } + + let attr_messages_nix = if !attr_messages.is_empty() { + "{\n".to_owned() + &indent(attr_messages.join("\n\n"), 1) + "\n}" + } else { + "{ }".to_owned() + }; + let overlays_nix = if !overlay_expressions.is_empty() { + "[\n".to_owned() + &indent(overlay_expressions.join("\n"), 1) + "\n]" + } else { + "[ ]".to_owned() + }; + let nix_file = config.nix_file.to_str().ok_or_else(|| { + format!( + "Nix expression file path ‘{}’ not valid UTF-8 string.", + config.nix_file.display(), + ) + })?; + let all_messages_nix = formatdoc!( + " + let + packageSet = {nix_file}; + cleanPkgs = import packageSet {{ }}; + + pkgs = import packageSet {{ + overlays = {}; + }}; + in {} + ", + indent(overlays_nix, 2).trim(), + indent(attr_messages_nix, 0).trim() + ); + + if config.show_trace { + eprintln!("Nix expression:\n{}", all_messages_nix); + } + + let overlay_data = nix_eval_json(all_messages_nix, config.show_trace)?; + let external_reports = run_external_checks( + &overlay_data.keys().map(|k| k.clone()).collect::>(), + &HashSet::from_iter(config.excluded_rules), + )?; + let overlay_reports = HashMap::from_iter( + overlay_data + .into_iter() + .map(|(attr, reports)| (attr.name, reports)), + ); + let all_messages = concatenate_messages(&mut [overlay_reports, external_reports]); + + if config.json { + let encoded_messages = serde_json::to_string(&all_messages) + .map_err(|err| format!("Unable to serialize result messages: {}", err.to_string()))?; + println!("{}", encoded_messages); + } else { + for (attr_name, reports) in all_messages.into_iter() { + eprintln!( + "{}", + bold(&format!("When evaluating attribute ‘{attr_name}’:")) + ); + if !reports.is_empty() { + eprintln!( + "{}", + reports + .into_iter() + .map(render_report_ansi) + .collect::>() + .join("\n") + ); + } else { + eprintln!("{}", green("No issues found.")); + } + eprintln!(""); + } + } + + Ok(()) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..bc7e290 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,73 @@ +use clap::Parser; +use nixpkgs_hammering::{hammer, Config}; +use std::{collections::HashSet, path::PathBuf}; + +/// Check package expressions for common mistakes. +#[derive(Parser, Debug)] +struct Args { + /// Evaluate attributes in given path. + /// + /// The path needs to be importable by Nix and the imported value + /// has to accept attribute set with overlays attribute as an argument. + /// + /// Defaults to current working directory. + #[arg(long, short = 'f', value_name = "FILE")] + file: Option, + + /// Show trace when error occurs. + #[arg(long, default_value_t = false)] + show_trace: bool, + + /// Avoid catching evaluation errors (for debugging). + #[arg(long, default_value_t = false)] + do_not_catch_errors: bool, + + /// Output results as JSON. + #[arg(long, default_value_t = false)] + json: bool, + + /// Rule to exclude (can be passed repeatedly). + #[arg(long, short = 'e', value_name = "rule")] + excluded: Vec, + + /// Attribute path of package to check. + #[arg(value_name = "attr-path", required = true)] + attr_paths: Vec, +} + +fn main() -> Result<(), String> { + let Args { + file, + show_trace, + do_not_catch_errors, + json, + excluded, + attr_paths, + } = Args::parse(); + + // Absolutize so we can refer to it from Nix. + let nix_file = if let Some(file) = file { + file.canonicalize().map_err(|err| { + format!( + "Unable to resolve path to Nix expression: {}", + err.to_string() + ) + })? + } else { + std::env::current_dir().map_err(|err| { + format!( + "Unable to determine current working directory: {}", + err.to_string() + ) + })? + }; + + hammer(Config { + nix_file, + show_trace, + do_not_catch_errors, + json, + excluded_rules: HashSet::from_iter(excluded), + attr_paths, + }) +} diff --git a/src/model.rs b/src/model.rs new file mode 100644 index 0000000..e55668e --- /dev/null +++ b/src/model.rs @@ -0,0 +1,103 @@ +use rust_checks::common_structs; +use serde::{Deserialize, Serialize}; +use std::{fmt::Display, path::PathBuf}; + +#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Severity { + Error, + Warning, + Notice, +} +impl Display for Severity { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Severity::Error => write!(f, "error"), + Severity::Warning => write!(f, "warning"), + Severity::Notice => write!(f, "notice"), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash)] +pub struct SourceLocation { + pub file: PathBuf, + pub line: usize, + pub column: Option, +} + +fn pb_to_string(buf: PathBuf) -> Result { + Ok(buf + .to_str() + .ok_or_else(|| format!("File path ‘{}’ not a valid UTF-8 string", buf.display()))? + .to_owned()) +} + +impl TryInto for SourceLocation { + type Error = String; + + fn try_into(self) -> Result { + let SourceLocation { file, line, column } = self; + Ok(common_structs::SourceLocation { + file: pb_to_string(file)?, + line, + column, + }) + } +} + +fn default_link() -> bool { + true +} + +fn default_severity() -> Severity { + Severity::Warning +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Report { + pub name: String, + pub msg: String, + #[serde(default)] + pub locations: Vec, + #[serde(default = "default_link")] + pub link: bool, + #[serde(default = "default_severity")] + pub severity: Severity, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Eq, Hash)] +pub struct CheckedAttr { + /// name of attr, e.g. python38Packages.numpy + pub name: String, + /// location in which the attr is defined, if exist + pub location: Option, + /// path to the .drv file, if exists + pub drv: Option, + /// path the the output of the drv in the nix store, if exists + pub output: Option, +} + +impl TryInto for CheckedAttr { + type Error = String; + + fn try_into(self) -> Result { + let CheckedAttr { + name, + location, + drv, + output, + } = self; + + let location = location.map(|l| l.try_into()).transpose()?; + let drv = drv.map(pb_to_string).transpose()?; + let output = output.map(pb_to_string).transpose()?; + + Ok(common_structs::CheckedAttr { + name, + location, + drv, + output, + }) + } +} diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..f5d0303 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,62 @@ +use std::{ + io::Write as _, + process::{Command, Stdio}, +}; + +pub(crate) fn optional_env(name: &str) -> Result, String> { + match std::env::var(name) { + Ok(value) => return Ok(Some(value)), + Err(std::env::VarError::NotPresent) => Ok(None), + Err(err) => { + return Err(format!( + "Unable to decode ‘{name}’ environment variable: {err}" + )) + } + } +} + +pub(crate) fn run_command_with_input( + program: &str, + args: &[&str], + input: &str, +) -> Result { + let mut child = Command::new(program) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .map_err(|err| format!("Unable to spawn program ‘{program}’: {}", err.to_string()))?; + + { + let Some(mut child_stdin) = child.stdin.take() else { + return Err(format!("Unable to acquire stdin handle of ‘{program}’")); + }; + + child_stdin.write_all(input.as_bytes()).map_err(|err| { + format!( + "Unable to write to stdin of ‘{program}’: {}", + err.to_string() + ) + })?; + } + + let output = child + .wait_with_output() + .map_err(|err| format!("Unable to wait on ‘{program}’: {}", err.to_string()))?; + + let stdout = String::from_utf8(output.stdout).map_err(|err| { + format!( + "Unable to parse output of ‘{program}’ as UTF-8: {}", + err.to_string() + ) + })?; + + Ok(stdout) +} + +pub(crate) fn indent(text: String, steps: usize) -> String { + text.split("\n") + .map(|line| " ".repeat(4 * steps * (!line.is_empty() as usize)) + line) + .collect::>() + .join("\n") +} diff --git a/tools/nixpkgs-hammer b/tools/nixpkgs-hammer deleted file mode 100755 index 9553fb6..0000000 --- a/tools/nixpkgs-hammer +++ /dev/null @@ -1,410 +0,0 @@ -#!/usr/bin/env python3 - -import argparse -import io -import json -import os -import subprocess -import sys -import textwrap -from collections import defaultdict -from dataclasses import asdict, dataclass, field, is_dataclass -from pathlib import Path -from typing import Any, Dict, Iterable, List, Optional, Set, Tuple - - -@dataclass(frozen=True) -class SourceLocation: - file: Path - line: int - column: Optional[int] = field(default=None) - - -@dataclass -class Report: - name: str - msg: str - locations: List[SourceLocation] - link: bool = field(default=True) - severity: str = field(default="warning") - - -@dataclass(frozen=True) -class Attr: - # name of attr, e.g. python38Packages.numpy - name: str - # location in which the attr is defined, if exist - location: Optional[SourceLocation] - # path to the .drv file, if exists - drv: Optional[Path] - # path the the output of the drv in the nix store, if exists - output: Optional[Path] - - -class JSONEncoder(json.JSONEncoder): - def default(self, o: Any) -> Any: - if is_dataclass(o): - return asdict(o) - - if isinstance(o, Path): - return str(o) - - # fallback to superclass - return super().default(o) - - -def get_check_programs() -> Set[str]: - # Each rule that is implemented as an external check program rather - # than an overlay is installed onto our PATH, and the names of the - # rules put into this environment variable. - ast_checks = os.environ.get("AST_CHECK_NAMES") - - if ast_checks: - return set(ast_checks.split(":")) - - return set() - - -def run_external_checks( - attrs: List[Attr], excluded_rules: List[str] -) -> Dict[str, List[Report]]: - rules = get_check_programs() - set(excluded_rules) - if len(rules) == 0: - return {} - - encoded_attrs = json.dumps(attrs, cls=JSONEncoder) - result: Dict[Attr, List[Report]] = defaultdict(list) - - if "no-build-output" not in excluded_rules: - names_without_outputs = [ - a.name for a in attrs if a.output is None or not a.output.exists() - ] - for name in names_without_outputs: - result[name].append( - Report( - name="no-build-output", - msg=f"‘{name}’ has not yet been built. Checks that rely on the presence of build logs are skipped.", - link=False, - locations=[], - severity="notice", - ) - ) - - for rule in rules: - try: - json_text = subprocess.check_output([rule], text=True, input=encoded_attrs) - except subprocess.CalledProcessError: - print( - red( - textwrap.dedent( - f""" - Rule ‘{rule}’ failed with input ‘{encoded_attrs}’. - This is a bug. Please file a ticket on GitHub. - """ - ) - ), - file=sys.stderr, - ) - raise - if len(json_text) > 0: - for attr_name, reports in json.loads(json_text).items(): - result[attr_name].extend(load_reports(reports)) - - return dict(result) - - -def concatenate_messages( - *report_sources: Dict[str, List[Report]] -) -> Dict[Attr, List[Report]]: - result = defaultdict(list) - for m in report_sources: - for attr_name, report in m.items(): - result[attr_name].extend(report) - return dict(result) - - -def escape_attr(attr: str) -> str: - return ".".join(f'"{section}"' for section in attr.split(".")) - - -def nix_eval_json(expr: str, show_trace: bool = False) -> Dict[Attr, List[Report]]: - args = ["nix-instantiate", "--strict", "--json", "--eval", "-"] - - if show_trace: - args.append("--show-trace") - - json_text = subprocess.check_output(args, text=True, input=expr) - - def maybe_path(s: Optional[str]) -> Optional[Path]: - if s: - return Path(s) - return None - - def maybe_location(s: Optional[Dict]) -> Optional[SourceLocation]: - if s and isinstance(s.get("line"), str) and s["line"]: - s["line"] = int(s["line"]) - return SourceLocation(**s) - return None - - result: Dict[Attr, List[Report]] = {} - for name, data in json.loads(json_text).items(): - attr = Attr( - name=name, - output=maybe_path(data.get("outputPath")), - drv=maybe_path(data.get("drvPath")), - location=maybe_location(data["location"]), - ) - result[attr] = load_reports(data["report"]) - - return result - - -def load_reports(report_datas: List[Dict[str, Any]]) -> List[Report]: - reports = [] - for report_data in report_datas: - locations = [] - for location_data in report_data.pop("locations", []): - loc = SourceLocation(**location_data) - locations.append(loc) - report = Report( - locations=locations, - **report_data, - ) - reports.append(report) - return reports - - -def bold(msg: str) -> str: - return f"{msg}" - - -def yellow(msg: str) -> str: - return f"{msg}" - - -def red(msg: str) -> str: - return f"{msg}" - - -def green(msg: str) -> str: - return f"{msg}" - - -def indent(text: str, steps=1) -> str: - return textwrap.indent(text, " " * 4 * steps) - - -def render_code_location_ansi(loc: SourceLocation) -> str: - with open(loc.file, "r") as opened_file: - all_lines = opened_file.read().splitlines() - line_contents = all_lines[loc.line - 1] - line_spaces = " " * len(str(loc.line)) - column = loc.column if loc.column is not None else 1 - pointer = " " * (column - 1) + "^" - rendered_loc_parts = [loc.file, str(loc.line)] - if loc.column is not None: - rendered_loc_parts.append(str(loc.column)) - - location_lines = [ - f"Near {':'.join(rendered_loc_parts)}:", - line_spaces + " |", - str(loc.line) + " | " + line_contents, - line_spaces + " | " + pointer, - ] - - return "\n".join(location_lines) - - -def render_report_ansi(report: Report) -> str: - color = red if report.severity == "error" else yellow - - message_lines = [ - color(f"{report.severity}: {report.name}"), - report.msg, - ] + [render_code_location_ansi(loc) for loc in report.locations] - - if report.link: - message_lines.append( - f"See: https://github.com/jtojnar/nixpkgs-hammering/blob/master/explanations/{report.name}.md", - ) - - return "\n".join(message_lines) - - -def main(args): - script_dir = Path(__file__).parent - overlay_generators_path = (script_dir.parent / "overlays").absolute() - lib_dir = (script_dir.parent / "lib").absolute() - - try_eval = "(value: { inherit value; success = true; })" if args.do_not_catch_errors else "builtins.tryEval" - - attr_messages = [] - - for attr in args.attr_paths: - attr_messages.append( - textwrap.dedent( - f""" - "{attr}" = - let - result = {try_eval} pkgs.{escape_attr(attr)} or null; - maybeReport = {try_eval} (result.value.__nixpkgs-hammering-state.reports or []); - in - if !(result.success && maybeReport.success) then - {{ - report = [ {{ - name = "EvalError"; - msg = "Cannot evaluate attribute ‘{attr}’ in ‘${{packageSet}}’."; - severity = "warning"; - link = false; - }} ]; - location = null; - outputPath = null; - drvPath = null; - }} - else if result.value == null then - {{ - report = [ {{ - name = "AttrPathNotFound"; - msg = "Packages in ‘${{packageSet}}’ do not contain ‘{attr}’ attribute."; - severity = "error"; - link = false; - }} ]; - location = null; - outputPath = null; - drvPath = null; - }} - else - {{ - report = if maybeReport.success then maybeReport.value else []; - location = - let - position = result.value.meta.position or null; - posSplit = builtins.split ":" result.value.meta.position; - in - if position == null then - null - else {{ - file = builtins.elemAt posSplit 0; - line = builtins.elemAt posSplit 2; - }}; - outputPath = result.value.outPath; - drvPath = result.value.drvPath; - }}; - """ - ) - ) - - # Our overlays need to know the built attributes so that they can check only them. - # We do it by using functions that return overlays so we need to instantiate them. - overlay_expressions = [] - for overlay_generator in overlay_generators_path.glob("*"): - if overlay_generator.stem in args.excluded_rules: - continue - - overlay = overlay_generator.name - overlay_expressions.append(f"(import {overlay_generators_path}/{overlay})") - - attr_messages_nix = ( - "{\n" + indent("".join(attr_messages)) + "\n}" if attr_messages else "{ }" - ) - overlays_nix = ( - "[\n" + indent("\n".join(overlay_expressions)) + "\n]" - if overlay_expressions - else "[ ]" - ) - all_messages_nix = textwrap.dedent( - f""" - let - packageSet = {args.nix_file}; - cleanPkgs = import packageSet {{ }}; - - pkgs = import packageSet {{ - overlays = {indent(overlays_nix, 2 + 2).strip()}; - }}; - in {indent(attr_messages_nix, 2 + 0).strip()} - """ - ) - - if args.show_trace: - print("Nix expression:", all_messages_nix, file=sys.stderr) - - overlay_data = nix_eval_json(all_messages_nix, args.show_trace) - overlay_reports = {attr.name: reports for attr, reports in overlay_data.items()} - external_reports = run_external_checks( - list(overlay_data.keys()), args.excluded_rules - ) - all_messages = concatenate_messages(overlay_reports, external_reports) - - if args.json: - print(json.dumps(all_messages, cls=JSONEncoder)) - else: - for attr_name, reports in all_messages.items(): - print(bold(f"When evaluating attribute ‘{attr_name}’:"), file=sys.stderr) - if len(reports) > 0: - print( - "\n".join([render_report_ansi(report) for report in reports]), - file=sys.stderr, - ) - else: - print(green("No issues found."), file=sys.stderr) - print(file=sys.stderr) - - -if __name__ == "__main__": - parser = argparse.ArgumentParser( - prog="nixpkgs-hammer", - description="check package expressions for common mistakes", - ) - parser.add_argument( - "-f", - "--file", - dest="nix_file", - metavar="FILE", - # Absolutize so we can refer to it from Nix. - type=lambda p: Path(p).resolve(strict=True), - # Nix defaults to current directory when file not specified. - default=Path.cwd(), - help=( - "evaluate attributes in given path rather than the default " - "(current working directory). The path needs to be importable " - "by Nix and the imported value has to accept attribute set " - "with overlays attribute as an argument." - ), - ) - parser.add_argument( - "--show-trace", - dest="show_trace", - action="store_true", - help="show trace when error occurs", - ) - parser.add_argument( - "--do-not-catch-errors", - dest="do_not_catch_errors", - action="store_true", - help="avoid catching evaluation errors (for debugging)", - ) - parser.add_argument( - "--json", - dest="json", - action="store_true", - help="Output results as JSON", - ) - parser.add_argument( - "-e", - "--exclude", - metavar="rule", - dest="excluded_rules", - action="append", - default=[], - help="rule to exclude (can be passed repeatedly)", - ) - parser.add_argument( - "attr_paths", - metavar="attr-path", - nargs="+", - help="Attribute path of package to update", - ) - - args = parser.parse_args() - - main(args)