├── examples ├── no-EtaReduce.nix ├── UpdateEmptySet.1.nix ├── EmptyVariadicParamSet.nix ├── UnusedLetBind.1.nix ├── NegateAtom.nix ├── UnusedLetBind.nix ├── EmptyLet.nix ├── UnneededRec.nix ├── EmptyInherit.nix ├── UpdateEmptySet.nix ├── AlphabeticalArgs.nix ├── AlphabeticalBindings.nix ├── DIYInherit.nix ├── SetLiteralUpdate.nix ├── BetaReduction.nix ├── FreeLetInFunc.nix ├── UnusedArg.nix ├── UnfortunateArgName.nix ├── UnneededAntiquote.nix ├── EtaReduce.nix ├── SequentialLet.nix ├── LetInInheritRecset.nix └── ListLiteralConcat.nix ├── Setup.hs ├── dist ├── setup-config └── cabal-config-flags ├── usage.sh ├── src ├── Data │ └── Pair.hs └── Nix │ ├── Linter.hs │ └── Linter │ ├── Tools │ └── FreeVars.hs │ ├── Traversals.hs │ ├── Utils.hs │ ├── Tools.hs │ ├── Types.hs │ └── Checks.hs ├── update_readme.sh ├── .gitignore ├── default.nix ├── CHANGELOG.md ├── README.md.gpp ├── main ├── Opts.hs └── Main.hs ├── LICENSE ├── .travis.yml ├── README.md ├── .github └── workflows │ └── ci.yml ├── tests └── Main.hs └── nix-linter.cabal /examples/no-EtaReduce.nix: -------------------------------------------------------------------------------- 1 | x: x x -------------------------------------------------------------------------------- /examples/UpdateEmptySet.1.nix: -------------------------------------------------------------------------------- 1 | { x = 1; } // { } -------------------------------------------------------------------------------- /examples/EmptyVariadicParamSet.nix: -------------------------------------------------------------------------------- 1 | { ... }: foo 2 | -------------------------------------------------------------------------------- /Setup.hs: -------------------------------------------------------------------------------- 1 | import Distribution.Simple 2 | main = defaultMain 3 | -------------------------------------------------------------------------------- /dist/setup-config: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Synthetica9/nix-linter/HEAD/dist/setup-config -------------------------------------------------------------------------------- /usage.sh: -------------------------------------------------------------------------------- 1 | set -euxo pipefail 2 | 3 | $(nix-build --no-out-link)/bin/nix-linter --help 4 | -------------------------------------------------------------------------------- /examples/UnusedLetBind.1.nix: -------------------------------------------------------------------------------- 1 | let 2 | x = 0; 3 | y = 1; 4 | in f rec { 5 | inherit x; 6 | } -------------------------------------------------------------------------------- /examples/NegateAtom.nix: -------------------------------------------------------------------------------- 1 | { 2 | # Why negate when you already know the outcome? 3 | bad = !true; 4 | 5 | good = false; 6 | } 7 | -------------------------------------------------------------------------------- /examples/UnusedLetBind.nix: -------------------------------------------------------------------------------- 1 | { 2 | # A let binding that isn't used can be left out: 3 | bad = let x = 1; in y; 4 | good = y; 5 | } 6 | -------------------------------------------------------------------------------- /dist/cabal-config-flags: -------------------------------------------------------------------------------- 1 | --verbose=1--ghc--prefix=/home/synthetica/.cabal--user--extra-prog-path=/home/synthetica/.cabal/bin--solver=modular -------------------------------------------------------------------------------- /examples/EmptyLet.nix: -------------------------------------------------------------------------------- 1 | { 2 | # Empty `let` blocks are a no-op. 3 | bad = let in x; 4 | 5 | # Best is to remove them. 6 | good = x; 7 | } 8 | -------------------------------------------------------------------------------- /examples/UnneededRec.nix: -------------------------------------------------------------------------------- 1 | { 2 | # When a set has no self-references, the `rec` can be removed. 3 | bad = rec {}; 4 | 5 | # As follows: 6 | good = {}; 7 | } 8 | -------------------------------------------------------------------------------- /examples/EmptyInherit.nix: -------------------------------------------------------------------------------- 1 | { 2 | # Empty `inherit` statements are a no-op. 3 | bad = { inherit (x); inherit; }; 4 | 5 | # Best is to remove them: 6 | good = {}; 7 | } 8 | -------------------------------------------------------------------------------- /examples/UpdateEmptySet.nix: -------------------------------------------------------------------------------- 1 | 2 | { 3 | # Don't update an empty set: 4 | bad = { } // { x = 1; }; 5 | 6 | # Use the set you are updating with instead: 7 | good = { x = 1; }; 8 | } 9 | -------------------------------------------------------------------------------- /examples/AlphabeticalArgs.nix: -------------------------------------------------------------------------------- 1 | { 2 | # Checks that all arguments of argument set in alphabetical order: 3 | bad = {b, a}: 1; 4 | 5 | # Alphabetize to avoid: 6 | good = {a, b}: 1; 7 | } 8 | -------------------------------------------------------------------------------- /examples/AlphabeticalBindings.nix: -------------------------------------------------------------------------------- 1 | { 2 | # Check that all bindings in a set are in alphabetical order: 3 | bad = {b = 1; a = 2;}; 4 | 5 | # Alphabetize to avoid: 6 | good = {a = 2; b = 1;}; 7 | } 8 | -------------------------------------------------------------------------------- /examples/DIYInherit.nix: -------------------------------------------------------------------------------- 1 | { 2 | # Checks for so called "DIY Inherit" (binding a variable to the same name): 3 | bad = { x = x; }; 4 | 5 | # This can be done better with `inherit`: 6 | good = { inherit x; }; 7 | } 8 | -------------------------------------------------------------------------------- /examples/SetLiteralUpdate.nix: -------------------------------------------------------------------------------- 1 | { 2 | # Instead of merging two set literals with //: 3 | bad = { a = 1; } // { b = 2; }; 4 | 5 | # Consider writing the result instead: 6 | good = { a = 1; } // { b = 2; }; 7 | } 8 | -------------------------------------------------------------------------------- /examples/BetaReduction.nix: -------------------------------------------------------------------------------- 1 | { 2 | # Check whether a beta reduction can be done: 3 | bad = (x: x + x) (y + 1); 4 | 5 | # In the general case, this can be replaced with a `let` block: 6 | good = let x = y + 1; in x + x; 7 | } 8 | -------------------------------------------------------------------------------- /examples/FreeLetInFunc.nix: -------------------------------------------------------------------------------- 1 | { 2 | # The binindgs here don't use the arguments that are abstracted: 3 | bad = x : let y = z; in x + y; 4 | 5 | # We can pull them out of said abstraction: 6 | good = let y = z; in x : x + y; 7 | } 8 | -------------------------------------------------------------------------------- /examples/UnusedArg.nix: -------------------------------------------------------------------------------- 1 | # Checks for "simple" arguments that are never used: 2 | x : y 3 | 4 | # Of course, sometimes this is needed. In these cases, you can rename the 5 | # arguments to start with `_`, e.g. `_x : y` or simply `_ : y`. 6 | -------------------------------------------------------------------------------- /examples/UnfortunateArgName.nix: -------------------------------------------------------------------------------- 1 | { 2 | # In some cases, changing the name used in an abstraction can make use of an 3 | # `inherit` possible: 4 | bad = x: { y = x; }; 5 | 6 | # Change `x` to `y` to enable this: 7 | good = y: { inherit y; }; 8 | } 9 | -------------------------------------------------------------------------------- /examples/UnneededAntiquote.nix: -------------------------------------------------------------------------------- 1 | { 2 | # Sometimes (but not in all cases! nix-linter does not detect the difference 3 | # between these cases!), antiquoting can be redundant. 4 | bad = "${x}"; 5 | 6 | # In these cases, one can remove the quotes and antiquotes: 7 | good = x; 8 | } 9 | -------------------------------------------------------------------------------- /src/Data/Pair.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE DeriveFoldable #-} 2 | {-# LANGUAGE DeriveFunctor #-} 3 | 4 | 5 | module Data.Pair where 6 | 7 | import Control.Monad (join) 8 | 9 | data Pair a = Pair a a deriving (Functor, Foldable, Show) 10 | 11 | dup :: a -> Pair a 12 | dup = join Pair 13 | -------------------------------------------------------------------------------- /src/Nix/Linter.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE LambdaCase #-} 2 | {-# LANGUAGE OverloadedStrings #-} 3 | {-# LANGUAGE ScopedTypeVariables #-} 4 | 5 | module Nix.Linter ( combineChecks, checks, AvailableCheck(..), multiChecks 6 | , parseCheckArg, checkCategories) where 7 | 8 | import Nix.Linter.Checks 9 | -------------------------------------------------------------------------------- /update_readme.sh: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env nix-shell 2 | #! nix-shell -i bash -p gpp bash 3 | 4 | # See https://vaneyckt.io/posts/safer_bash_scripts_with_set_euxo_pipefail/ 5 | set -euxo pipefail 6 | 7 | # Fixes troubles with Travis WRT insufficient disk space 8 | unset TMPDIR 9 | 10 | gpp -H -x README.md.gpp -o README.md 11 | -------------------------------------------------------------------------------- /examples/EtaReduce.nix: -------------------------------------------------------------------------------- 1 | { 2 | # When you have a function abstraction, only to immediately apply the 3 | # argument to a function, this is called an η-abstraction (or eta-abstraction) 4 | # See also: https://wiki.haskell.org/Eta_conversion 5 | bad = x: f x; 6 | 7 | # Generally, it nicer to be direct: 8 | good = f; 9 | } 10 | -------------------------------------------------------------------------------- /examples/SequentialLet.nix: -------------------------------------------------------------------------------- 1 | { 2 | # This doesn't need to split into multiple blocks: 3 | bad = 4 | let 5 | x = 1; 6 | in 7 | let 8 | y = 2; 9 | in 10 | { z = x + y; }; 11 | 12 | # Merge those blocks instead: 13 | good = 14 | let 15 | x = 1; 16 | y = 2; 17 | in 18 | { z = x + y; }; 19 | } 20 | -------------------------------------------------------------------------------- /src/Nix/Linter/Tools/FreeVars.hs: -------------------------------------------------------------------------------- 1 | module Nix.Linter.Tools.FreeVars (freeVars, freeVars') where 2 | import Data.Set (Set) 3 | 4 | import Nix.Expr.Types 5 | import Nix.Expr.Types.Annotated 6 | import Nix.TH (freeVars) 7 | 8 | freeVars' :: NExprLoc -> Set VarName 9 | freeVars' = freeVars . stripAnnotation 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | dist-* 3 | cabal-dev 4 | *.o 5 | *.hi 6 | *.chi 7 | *.chs.h 8 | *.dyn_o 9 | *.dyn_hi 10 | .hpc 11 | .hsenv 12 | .cabal-sandbox/ 13 | cabal.sandbox.config 14 | *.prof 15 | *.aux 16 | *.hp 17 | *.eventlog 18 | .stack-work/ 19 | cabal.project.local 20 | cabal.project.local~ 21 | .HTF/ 22 | .ghc.environment.* 23 | 24 | result 25 | result-* 26 | 27 | all_warnings 28 | src/Main 29 | .vscode -------------------------------------------------------------------------------- /examples/LetInInheritRecset.nix: -------------------------------------------------------------------------------- 1 | { 2 | # Instead of using a `let` block here, we can directly specify this within the 3 | # recursive set. 4 | bad = 5 | let 6 | x = 5; 7 | in rec { 8 | inherit x; 9 | }; 10 | 11 | # This can be done as follows: 12 | good = 13 | rec { 14 | x = 5; 15 | }; 16 | 17 | # Note that this doesn't need to work for non-recursive sets. 18 | } 19 | -------------------------------------------------------------------------------- /examples/ListLiteralConcat.nix: -------------------------------------------------------------------------------- 1 | { 2 | # When concatenating list literals: 3 | bad = [ a ] ++ [ b ]; 4 | 5 | # Just write the concatenated list instead! 6 | good = [ a b ]; 7 | 8 | note = { 9 | # Note: the following can also raise this warning: 10 | bad = with x; [ a ] ++ [ b ]; 11 | 12 | # This is because the `with` is parsed to affect the scope of both lists! 13 | # To silence this warning in this case, use parentheses: 14 | good = (with x; [ a ]) ++ [ b ]; 15 | }; 16 | } 17 | -------------------------------------------------------------------------------- /default.nix: -------------------------------------------------------------------------------- 1 | { nixpkgsSrc ? builtins.fetchTarball { 2 | url = 3 | "https://github.com/NixOS/nixpkgs/archive/38296d89d41c26a127b69c52421fbee95ceb0d22.tar.gz"; # haskell-updates 4 | sha256 = "108c4wm4vfqkgd6awpaskakq26f8ajx729s4bxqvvvfflrzwrlrv"; 5 | }, pkgs ? import nixpkgsSrc { }, compiler ? null }: 6 | 7 | let 8 | haskellPackages = if compiler == null then 9 | pkgs.haskellPackages 10 | else 11 | pkgs.haskell.packages.${compiler}; 12 | 13 | in haskellPackages.developPackage { 14 | name = ""; 15 | overrides = self: super: { 16 | streamly = self.streamly_0_8_0; 17 | }; 18 | root = pkgs.nix-gitignore.gitignoreSource [ ] ./.; 19 | } 20 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Revision history for nix-linter 2 | 3 | ## 0.2.0.4 -- 2021-07-20 4 | 5 | * Changes for streamly 0.8 6 | 7 | ## 0.2.0.3 -- 2021-07-20 8 | 9 | * Relax bounds on hnix 10 | * Tighten upper bound on streamly 11 | 12 | ## 0.2.0.2 -- 2021-06-16 13 | 14 | * Fix compilation with recent hnix 15 | * Better unused argument warnings 16 | 17 | ## ??? -- 2019-??-?? 18 | * Move more internal strings from String to Text 19 | * Add descriptions of offenses 20 | * Add `nix-linter -` to lint the contents of STDIN. 21 | 22 | ## 0.2.0.0 -- 2018-12-19 23 | 24 | * Add initial functionality. 25 | 26 | ## 0.1.0.0 -- 2018-10-17 27 | 28 | * First version. Released on an unsuspecting world. 29 | -------------------------------------------------------------------------------- /README.md.gpp: -------------------------------------------------------------------------------- 1 | # `nix-linter` 2 | 3 | [![Build Status](https://travis-ci.org/Synthetica9/nix-linter.svg?branch=master)](https://travis-ci.org/Synthetica9/nix-linter) 4 | 5 | `nix-linter` is a program to check for several common mistakes or stylistic 6 | errors in Nix expressions, such as unused arguments, empty let blocks, 7 | etcetera. 8 | 9 | ## Usage 10 | 11 | First, setup cachix: 12 | 13 | ```sh 14 | cachix use nix-linter 15 | ``` 16 | 17 | Then clone the repo and `cd` into it: 18 | 19 | ```sh 20 | git clone https://github.com/Synthetica9/nix-linter 21 | cd nix-linter 22 | ``` 23 | 24 | Finally, you can run the application with: 25 | 26 | ```sh 27 | <#include usage.sh> 28 | ``` 29 | 30 | ``` 31 | <#exec bash ./usage.sh> 32 | ``` 33 | -------------------------------------------------------------------------------- /src/Nix/Linter/Traversals.hs: -------------------------------------------------------------------------------- 1 | -- | Functions ported to Data.Fix from Data.Generics.Fixplate (ported as I need them) 2 | module Nix.Linter.Traversals (contextList, universe) where 3 | 4 | 5 | import Data.Fix 6 | import qualified Data.Generics.Fixplate as F 7 | 8 | import Control.Arrow ((***)) 9 | import qualified Data.Generics.Fixplate.Traversals as T 10 | 11 | fixToMu :: Functor f => Fix f -> F.Mu f 12 | fixToMu = F.Fix . fmap fixToMu . unFix 13 | 14 | muToFix :: Functor f => F.Mu f -> Fix f 15 | muToFix = Fix . fmap muToFix . F.unFix 16 | 17 | contextList :: Traversable f => Fix f -> [(Fix f, Fix f -> Fix f)] 18 | contextList = fmap (muToFix *** ((muToFix .) . (. fixToMu))) . T.contextList . fixToMu 19 | 20 | -- If the functor constraint ever gives the slightest problem, I'm goingo change 21 | -- fixToMu and muToFix to just use unsafeCoerce instead. 22 | universe :: (Foldable f, Functor f) => Fix f -> [Fix f] 23 | universe = fmap muToFix . T.universe . fixToMu 24 | -------------------------------------------------------------------------------- /src/Nix/Linter/Utils.hs: -------------------------------------------------------------------------------- 1 | module Nix.Linter.Utils where 2 | 3 | import Data.Either (partitionEithers) 4 | 5 | -- |Logical implication 6 | (-->) :: Bool -> Bool -> Bool 7 | True --> False = False 8 | _ --> _ = True 9 | 10 | 11 | (<$$>) :: (Functor f, Functor g) => (a -> b) -> f (g a) -> f (g b) 12 | (<$$>) = fmap . fmap 13 | 14 | (<&>) :: Functor f => f a -> (a -> b) -> f b 15 | (<&>) = flip fmap 16 | 17 | 18 | choose :: [a] -> [(a, [a])] 19 | choose [] = [] 20 | choose (x : xs) = (x, xs) : ((x :) <$$> choose xs) 21 | 22 | (...) :: (c -> d) -> (a -> b -> c) -> a -> b -> d 23 | (...) = (.) . (.) 24 | 25 | sorted :: Ord a => [a] -> Bool 26 | sorted [] = True 27 | sorted xs = and $ (<=) <$> xs <*> tail xs 28 | 29 | sequenceEither :: [Either a b] -> Either [a] [b] 30 | sequenceEither x = case partitionEithers x of 31 | ([], rights) -> Right rights 32 | (lefts, _) -> Left lefts 33 | 34 | removeSuffix :: Eq a => [a] -> [a] -> [a] 35 | removeSuffix xs xs' 36 | | xs == xs' = [] 37 | removeSuffix xs (y:ys) = y:removeSuffix xs ys 38 | removeSuffix _ [] = [] 39 | 40 | eitherIO :: Either String a -> IO a 41 | eitherIO = either fail pure 42 | -------------------------------------------------------------------------------- /main/Opts.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE DeriveDataTypeable #-} 2 | {-# LANGUAGE RecordWildCards #-} 3 | 4 | module Opts where 5 | 6 | import Nix.Linter 7 | import System.Console.CmdArgs 8 | 9 | data NixLinter = NixLinter 10 | { check :: [String] 11 | , json :: Bool 12 | , json_stream :: Bool 13 | , recursive :: Bool 14 | , out :: FilePath 15 | , files :: [FilePath] 16 | , help_for :: [String] 17 | } deriving (Show, Data, Typeable) 18 | 19 | nixLinter :: NixLinter 20 | nixLinter = NixLinter 21 | { check = def &= name "W" &= help "checks to enable" 22 | , json = def &= help "Use JSON output" 23 | , json_stream = def &= name "J" &= help "Use a newline-delimited stream of JSON objects instead of a JSON list (implies --json)" 24 | , recursive = def &= help "Recursively walk given directories (like find)" 25 | , out = def &= help "File to output to" &= opt "-" &= typFile 26 | , files = def &= args &= typ "FILES" 27 | , help_for = def &= typ "CHECKS" 28 | } &= verbosity &= details (mkChecksHelp Nix.Linter.checks) &= program "nix-linter" 29 | 30 | mkChecksHelp :: [AvailableCheck] -> [String] 31 | mkChecksHelp xs = "Available checks (See `nix-linter --help-for [CHECK]` for more details): " : (mkDetails <$> xs) where 32 | mkDetails (AvailableCheck{..}) = " " ++ show category ++ mkDis defaultEnabled 33 | mkDis False = " (disabled by default)" 34 | mkDis _ = "" 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018, Patrick Hilhorst 2 | 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 11 | * Redistributions in binary form must reproduce the above 12 | copyright notice, this list of conditions and the following 13 | disclaimer in the documentation and/or other materials provided 14 | with the distribution. 15 | 16 | * Neither the name of Patrick Hilhorst nor the names of other 17 | contributors may be used to endorse or promote products derived 18 | from this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 21 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 22 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 23 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 24 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 25 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 26 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 27 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 28 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 29 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: nix 2 | script: 3 | - set -o pipefail 4 | - nix-env -iA cachix -f https://cachix.org/api/v1/install 5 | - "[[ -v NIXPKGS_CHANNEL ]] && ARG=\"--argstr nixpkgsCommit $NIXPKGS_CHANNEL\" || :" 6 | - nix-build -A nix-linter $ARG | (if [[ "$TRAVIS_BRANCH" = "master" ]]; then cachix push nix-linter; fi) 7 | env: # Alllow for failures 8 | matrix: 9 | include: 10 | - name: "Default build" 11 | - name: "NixOS 20.09" 12 | env: NIXPKGS_CHANNEL=nixos-20.09 13 | - name: "Nixpkgs Unstable" 14 | env: NIXPKGS_CHANNEL=nixpkgs-unstable 15 | allow_failures: 16 | - env: NIXPKGS_CHANNEL=nixpkgs-unstable 17 | fast_finish: true 18 | before_deploy: "./update_readme.sh" 19 | deploy: 20 | provider: pages 21 | target-branch: master 22 | keep-history: true 23 | skip-cleanup: true 24 | on: 25 | branch: master 26 | condition: 27 | - '! -v NIXPKGS_CHANNEL ' 28 | # Check that the README has changed. This is the only thing that we are currently using deploy for. 29 | - '-n "$(git diff README.md)"' 30 | # Check this, so we don't get a deploy loop 31 | - '$(git log -1 --pretty=format:"%ae") != "deploy@travis-ci.org"' 32 | file: 33 | README.md 34 | github_token: 35 | secure: IhhPdLSMuKEQDPmHheB1zvoFKTUT+lqKrkwbv6Pih7hbPKjtX2Y1VmBAGjfQRlXSz5876DAqYFNHe/avIvtkEMvnI0p24e5IWb1O5H/lNS3TpcmIF23umMDkJVbWXsP7qdBei1YGN/7Tr/IUZNZ+RD8VUR9SL1ozL6QNIvqFR6zqMFDlYCNkRMlQ3usO8NkBPaI+4WjhB3t8tlGJN9/r1CIizfLIE0g32v23RGCOV3gRNzS/M5Cb/ZfpC/6DyjjMkBUVrDAlxPOK/PO5Zn7xRycRUX5AlhO413FMfPX59lm/zWp+JJUJ5M/CZL4AqaUysl2IppBZPJzNlkRiYM0/V/ET328rPYvlzyOtr2rN+tjSv4x3dDw4t6Tt+QRlSyfkUn9kpcwijEf4pV8SrKAKSEBi7cHkyf4xq1f+dBEDBlj1I0rZ8nax3DNp2mL3DKrzX0PLZgVJzWKohbFXjCcWYl+o3XAnjjBvanxmjwB28i8rmlokc1lDSe84bunRi1vjmT5snEddgmPpPieRHr71seF7ixTLT1qW6Qa+JraT+cpQZcjoMr8BSyv+XeVK/Pk+wyuOIKnxBANMB7jevkGJVUazCEtxajOHbXhVGyG0qzfp9chNYxqWvYlbOXiHKgg1DRp++yxvBfp4Y0KwJFikTCidWwAIbnsfmKQdpjxblo4= 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `nix-linter` 2 | 3 | [![Build Status](https://travis-ci.org/Synthetica9/nix-linter.svg?branch=master)](https://travis-ci.org/Synthetica9/nix-linter) 4 | 5 | `nix-linter` is a program to check for several common mistakes or stylistic 6 | errors in Nix expressions, such as unused arguments, empty let blocks, 7 | etcetera. 8 | 9 | ## Usage 10 | 11 | First, setup cachix: 12 | 13 | ```sh 14 | cachix use nix-linter 15 | ``` 16 | 17 | Then clone the repo and `cd` into it: 18 | 19 | ```sh 20 | git clone https://github.com/Synthetica9/nix-linter 21 | cd nix-linter 22 | ``` 23 | 24 | Finally, you can run the application with: 25 | 26 | ```sh 27 | $(nix-build -A nix-linter)/bin/nix-linter --help 28 | 29 | ``` 30 | 31 | ``` 32 | The nix-linter program 33 | 34 | nix-linter [OPTIONS] [FILES] 35 | 36 | Common flags: 37 | -W --check=ITEM checks to enable 38 | -j --json Use JSON output 39 | -J --json-stream Use a newline-delimited stream of JSON objects 40 | instead of a JSON list (implies --json) 41 | -r --recursive Recursively walk given directories (like find) 42 | -o --out[=FILE] File to output to 43 | -h --help-for=CHECKS 44 | -? --help Display help message 45 | -V --version Print version information 46 | -v --verbose Loud verbosity 47 | -q --quiet Quiet verbosity 48 | 49 | Available checks (See `nix-linter --help-for [CHECK]` for more details): 50 | DIYInherit 51 | EmptyInherit 52 | EmptyLet 53 | EtaReduce 54 | FreeLetInFunc 55 | LetInInheritRecset 56 | ListLiteralConcat 57 | NegateAtom 58 | SequentialLet 59 | SetLiteralUpdate 60 | UnfortunateArgName 61 | UnneededRec 62 | UnusedArg 63 | UnusedLetBind 64 | UpdateEmptySet 65 | AlphabeticalArgs (disabled by default) 66 | AlphabeticalBindings (disabled by default) 67 | BetaReduction (disabled by default) 68 | EmptyVariadicParamSet (disabled by default) 69 | UnneededAntiquote (disabled by default) 70 | 71 | ``` 72 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # Based on https://kodimensional.dev/github-actions 2 | 3 | name: CI 4 | 5 | # Trigger the workflow on push or pull request, but only for the master branch 6 | on: 7 | pull_request: 8 | push: 9 | branches: [master] 10 | 11 | env: 12 | cabalConfig: --enable-tests --enable-benchmarks --enable-deterministic --write-ghc-environment-files=always 13 | 14 | jobs: 15 | cabal: 16 | name: ghc ${{ matrix.ghc }} 17 | runs-on: ${{ matrix.os }} 18 | strategy: 19 | matrix: 20 | os: [ubuntu-latest] 21 | cabal: [latest] 22 | ghc: ['8.10'] 23 | fail-fast: false 24 | 25 | steps: 26 | - uses: haskell/actions/setup@v1 27 | id: setup-haskell-cabal 28 | name: Setup Haskell 29 | with: 30 | ghc-version: ${{ matrix.ghc }} 31 | cabal-version: ${{ matrix.cabal }} 32 | 33 | - uses: actions/checkout@v2 34 | 35 | - name: Repository update 36 | run: | 37 | cabal v2-update 38 | 39 | # NOTE: Freeze is for the caching 40 | - name: Configuration freeze 41 | run: | 42 | cabal v2-freeze $cabalConfig 43 | 44 | - uses: actions/cache@v1 45 | name: Cache cabal-store 46 | with: 47 | path: | 48 | ${{ steps.setup-haskell-cabal.outputs.cabal-store }} 49 | dist-newstyle 50 | key: ${{ runner.os }}-${{ matrix.ghc }}-${{ hashFiles('cabal.project.freeze') 51 | }} 52 | restore-keys: ${{ runner.os }}-${{ matrix.ghc }}- 53 | 54 | - name: Install system dependencies 55 | run: sudo apt-get install libsodium-dev 56 | 57 | - name: Build dependencies 58 | run: | 59 | cabal build all --only-dependencies $cabalConfig 60 | 61 | - name: Build 62 | run: | 63 | cabal build all $cabalConfig 64 | 65 | - name: Test 66 | run: | 67 | cabal test all $cabalConfig 68 | 69 | nix: 70 | runs-on: ubuntu-latest 71 | steps: 72 | - uses: cachix/install-nix-action@v13 73 | - uses: cachix/cachix-action@v10 74 | with: 75 | name: nix-linter 76 | signingKey: '${{ secrets.CACHIX_PRIVATE_KEY }}' 77 | - uses: actions/checkout@v2 78 | - run: nix-build 79 | - run: nix-shell --run "echo ok" 80 | -------------------------------------------------------------------------------- /tests/Main.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE LambdaCase #-} 2 | {-# LANGUAGE TemplateHaskell #-} 3 | 4 | import Control.Monad.Trans (liftIO) 5 | import Data.Char (toLower) 6 | import Data.Foldable (for_) 7 | import Data.Function (on) 8 | import Data.Set ((\\)) 9 | import qualified Data.Set as Set 10 | import Data.Traversable (for) 11 | import System.Directory (doesFileExist, listDirectory) 12 | import System.FilePath (()) 13 | 14 | import Nix.Linter 15 | import Nix.Linter.Types 16 | import Nix.Linter.Utils 17 | import Paths_nix_linter 18 | 19 | import Nix.Expr.Types.Annotated 20 | import Nix.Parser 21 | 22 | import Test.Tasty 23 | import Test.Tasty.HUnit 24 | import Test.Tasty.TH 25 | 26 | 27 | stripExtension :: FilePath -> String 28 | stripExtension = takeWhile (/= '.') 29 | 30 | parseCategory name = do 31 | f <- eitherIO $ parseCheckArg name 32 | pure (f Set.empty) 33 | 34 | case_all_offense_categories_covered :: Assertion 35 | case_all_offense_categories_covered = do 36 | let available = category <$> checks 37 | all = [minBound..maxBound] :: [OffenseCategory] 38 | (assertEqual "" `on` Set.fromList) all available 39 | 40 | case_examples_match :: Assertion 41 | case_examples_match = do 42 | exampleDir <- liftIO $ getDataFileName "examples" 43 | examples <- liftIO $ listDirectory exampleDir 44 | for_ examples $ \example -> do 45 | let strippedName = stripExtension example 46 | category <- parseCategory strippedName 47 | let check = checkCategories $ Set.toList category 48 | 49 | parsed <- parseNixFileLoc (exampleDir example) >>= \case 50 | Right x -> pure x 51 | Left err -> assertFailure (show err) 52 | 53 | let offenses = Set.fromList $ offense <$> check parsed 54 | assertEqual strippedName offenses category 55 | 56 | case_all_categories_have_example :: Assertion 57 | case_all_categories_have_example = 58 | do 59 | let all = [minBound..maxBound] :: [OffenseCategory] 60 | exampleDir <- liftIO $ getDataFileName "examples" 61 | diff <- concat <$$> for all $ \cat -> do 62 | let path = exampleDir show cat <> ".nix" 63 | exists <- doesFileExist path 64 | pure $ [ cat | not exists ] 65 | assertBool ("Missing: " ++ show diff) (null diff) 66 | 67 | main = $defaultMainGenerator 68 | -------------------------------------------------------------------------------- /src/Nix/Linter/Tools.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE OverloadedStrings #-} 2 | 3 | 4 | module Nix.Linter.Tools where 5 | 6 | import Control.Monad (join) 7 | import Data.Fix 8 | import Data.List (find) 9 | import Data.List.NonEmpty (NonEmpty (..)) 10 | import Data.Set (member) 11 | import Data.Text (isPrefixOf, pack) 12 | 13 | import Nix.Expr.Types 14 | import Nix.Expr.Types.Annotated 15 | 16 | import Nix.Linter.Tools.FreeVars 17 | 18 | import Nix.Linter.Traversals 19 | import Nix.Linter.Utils 20 | 21 | 22 | hasRef, noRef :: VarName -> NExprLoc -> Bool 23 | hasRef name t = member name $ freeVars' t 24 | 25 | noRef = not ... hasRef 26 | 27 | getFreeVarName :: NExprLoc -> VarName 28 | getFreeVarName x = let 29 | candidates = pack . ("_freeVar" ++) . show <$> ([1..] :: [Integer]) 30 | -- We are guarranteed to find a good candidate, because candidates is 31 | -- infinite and x is strict 32 | Just var = find (not . (`member` freeVars' x)) candidates 33 | in var 34 | 35 | 36 | getFreeVar :: NExprLoc -> NExprLoc 37 | getFreeVar = Fix . NSym_ generated . getFreeVarName 38 | 39 | topLevelBinds :: NExprLoc -> ([Binding NExprLoc], NExprLoc, Bool) 40 | topLevelBinds e = case unFix e of 41 | NSet_ _ann NRecursive xs -> (xs, e, True) 42 | -- Nonrecursive, so no context. We make up a context that can't possibly be valid. 43 | NSet_ _ann NNonRecursive xs -> (xs, getFreeVar e, True) 44 | -- `let x = 1; y = x; in y` is valid, so e is the context! 45 | NLet_ _ xs _ -> (xs, e, False) 46 | -- Otherwise, our context is just empty! 47 | _ -> ([], e, False) 48 | 49 | generatedPos :: SourcePos 50 | generatedPos = let z = mkPos 1 in SourcePos "" z z 51 | 52 | generated :: SrcSpan 53 | generated = join SrcSpan generatedPos 54 | 55 | chooseTrees :: NExprLoc -> [(NExprLoc, NExprLoc)] 56 | chooseTrees e = do 57 | (inner, outer) <- contextList e 58 | pure (inner, outer $ getFreeVar e) 59 | 60 | values :: [Binding r] -> [r] 61 | values = (f =<<) where 62 | f (NamedVar _ x _) = [x] 63 | f _ = [] 64 | 65 | staticKeys :: [NKeyName x] -> [VarName] 66 | staticKeys xs = do 67 | StaticKey x <- xs 68 | pure x 69 | 70 | simpleBoundNames :: Binding x -> [VarName] 71 | simpleBoundNames (NamedVar (StaticKey x :| []) _ _) = [x] 72 | simpleBoundNames (Inherit _ xs _) = staticKeys xs 73 | simpleBoundNames _ = [] 74 | 75 | plainInherits :: VarName -> [Binding x] -> Bool 76 | plainInherits x xs = or $ do 77 | Inherit Nothing ys _ <- xs 78 | pure $ x `elem` staticKeys ys 79 | 80 | plainInheritsAnywhere :: VarName -> NExprLoc -> Bool 81 | plainInheritsAnywhere x e = any (plainInherits x . (\(a, _, _) -> a) . topLevelBinds) $ universe e 82 | 83 | nonIgnoredName :: VarName -> Bool 84 | nonIgnoredName x = not $ isPrefixOf "_" x 85 | -------------------------------------------------------------------------------- /nix-linter.cabal: -------------------------------------------------------------------------------- 1 | -- Initial nix-linter.cabal generated by cabal init. For further 2 | -- documentation, see http://haskell.org/cabal/users-guide/ 3 | 4 | -- The name of the package. 5 | name: nix-linter 6 | 7 | -- The package version. See the Haskell package versioning policy (PVP) 8 | -- for standards guiding when and how versions should be incremented. 9 | -- https://wiki.haskell.org/Package_versioning_policy 10 | -- PVP summary: +-+------- breaking API changes 11 | -- | | +----- non-breaking API additions 12 | -- | | | +--- code changes with no API change 13 | version: 0.2.0.4 14 | 15 | -- A short (one-line) description of the package. 16 | synopsis: Linter for Nix(pkgs), based on hnix 17 | 18 | -- A longer description of the package. 19 | -- description: 20 | 21 | -- URL for the project homepage or repository. 22 | homepage: https://github.com/Synthetica9/nix-linter 23 | 24 | -- The license under which the package is released. 25 | license: BSD3 26 | 27 | -- The file containing the license text. 28 | license-file: LICENSE 29 | 30 | -- The package author(s). 31 | author: Patrick Hilhorst 32 | 33 | -- An email address to which users can send suggestions, bug reports, and 34 | -- patches. 35 | maintainer: nix@hilhorst.be 36 | 37 | -- A copyright notice. 38 | -- copyright: 39 | 40 | category: Language 41 | 42 | build-type: Simple 43 | 44 | -- Extra files to be distributed with the package, such as examples or a 45 | -- README. 46 | extra-source-files: CHANGELOG.md 47 | 48 | -- Constraint on the version of Cabal needed to build this package. 49 | cabal-version: >=1.10 50 | 51 | data-files: 52 | examples/*.nix 53 | 54 | flag threaded 55 | default: True 56 | description: Build with multi-threading support 57 | 58 | library 59 | exposed-modules: 60 | Data.Pair, 61 | Nix.Linter, 62 | Nix.Linter.Checks, 63 | Nix.Linter.Tools, 64 | Nix.Linter.Tools.FreeVars, 65 | Nix.Linter.Traversals, 66 | Nix.Linter.Types, 67 | Nix.Linter.Utils 68 | 69 | build-depends: 70 | base >=4.9, 71 | hnix >=0.13 && < 0.15, 72 | data-fix, 73 | fixplate >= 0.1.7, 74 | cmdargs >= 0.10, 75 | aeson >= 1.3, 76 | containers >= 0.5, 77 | text >= 0.1.2.3 78 | 79 | hs-source-dirs: src 80 | 81 | default-language: Haskell2010 82 | 83 | ghc-options: -O2 -Wall -Werror -Wno-error=name-shadowing 84 | 85 | executable nix-linter 86 | main-is: Main.hs 87 | other-modules: 88 | Opts 89 | Paths_nix_linter 90 | hs-source-dirs: main 91 | default-language: Haskell2010 92 | build-depends: 93 | -- The main package: 94 | nix-linter, 95 | 96 | -- Packages only used here: 97 | streamly >=0.8, 98 | mtl >=1.1, 99 | path-io >=1.4, 100 | path >= 0.6, 101 | pretty-terminal >= 0.1 && < 0.2, 102 | 103 | -- Packages also used in the library: 104 | text, 105 | base, 106 | aeson, 107 | cmdargs, 108 | containers, 109 | hnix, 110 | bytestring 111 | 112 | if flag(threaded) 113 | ghc-options: -threaded -rtsopts -with-rtsopts=-N 114 | 115 | ghc-options: -O2 -Wall -Werror 116 | 117 | test-suite nix-linter-unit-tests 118 | hs-source-dirs: tests 119 | default-language: Haskell2010 120 | main-is: Main.hs 121 | type: exitcode-stdio-1.0 122 | 123 | other-modules: 124 | Paths_nix_linter 125 | 126 | build-depends: 127 | nix-linter, 128 | 129 | tasty, 130 | tasty-th, 131 | tasty-hunit, 132 | directory, 133 | filepath, 134 | 135 | base, 136 | containers, 137 | mtl, 138 | hnix 139 | -------------------------------------------------------------------------------- /main/Main.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE ApplicativeDo #-} 2 | {-# LANGUAGE FlexibleContexts #-} 3 | {-# LANGUAGE LambdaCase #-} 4 | {-# LANGUAGE MultiWayIf #-} 5 | {-# LANGUAGE OverloadedStrings #-} 6 | {-# LANGUAGE PartialTypeSignatures #-} 7 | {-# LANGUAGE RecordWildCards #-} 8 | {-# LANGUAGE ScopedTypeVariables #-} 9 | {-# LANGUAGE TupleSections #-} 10 | 11 | {-# OPTIONS_GHC -Wno-missing-signatures -Wno-name-shadowing #-} 12 | 13 | module Main where 14 | 15 | import Prelude hiding (log, readFile) 16 | 17 | import Control.Arrow ((>>>)) 18 | import Control.Monad (join, when) 19 | import Control.Monad.Trans (MonadIO, liftIO) 20 | import Data.Foldable (for_, traverse_) 21 | import Data.Function ((&)) 22 | import Data.IORef 23 | import Data.List (isSuffixOf, sortOn) 24 | import Data.Text (Text) 25 | import qualified Data.Text as T 26 | 27 | import Data.Text.IO 28 | 29 | import Path.Internal (toFilePath) 30 | import Path.IO (listDir, resolveDir') 31 | import System.Console.Pretty 32 | import System.Exit 33 | import System.IO (IOMode (..), stderr, stdout, withFile) 34 | 35 | import qualified Data.ByteString.Lazy as B 36 | 37 | import Nix.Parser 38 | 39 | import qualified Data.Set as Set 40 | 41 | import Streamly.Prelude ((.:)) 42 | import qualified Streamly.Prelude as S 43 | 44 | import Data.Aeson (encode) 45 | 46 | import Opts 47 | 48 | import Nix.Linter 49 | import Nix.Linter.Types 50 | import Nix.Linter.Utils 51 | 52 | import Paths_nix_linter 53 | 54 | import System.Console.CmdArgs 55 | 56 | getChecks :: [OffenseCategory] -> [String] -> Either [String] [OffenseCategory] 57 | getChecks defaults' check = let 58 | defaults = Set.fromList $ defaults' 59 | parsedArgs = sequenceEither $ parseCheckArg <$> check 60 | categories = (\fs -> foldl (flip ($)) defaults fs) <$> parsedArgs 61 | in Set.toList <$> categories 62 | 63 | getCombined :: [String] -> IO Check 64 | getCombined check = do 65 | let defaults = category <$> filter defaultEnabled checks 66 | enabled <- case getChecks defaults check of 67 | Right cs -> pure cs 68 | Left err -> do 69 | for_ err print 70 | exitFailure 71 | 72 | whenLoud $ do 73 | log "Enabled checks:" 74 | if null enabled 75 | then log " (None)" 76 | else for_ enabled $ \check -> do 77 | log $ "- " <> pShow check 78 | 79 | pure $ checkCategories enabled 80 | 81 | main :: IO () 82 | main = cmdArgs nixLinter >>= runChecks 83 | 84 | log :: Text -> IO () 85 | log = hPutStrLn stderr 86 | 87 | -- Example from https://hackage.haskell.org/package/streamly 88 | listDirRecursive :: (S.IsStream t, MonadIO m, MonadIO (t m), Monoid (t m FilePath)) => FilePath -> t m FilePath 89 | listDirRecursive path = resolveDir' path >>= readDir 90 | where 91 | readDir dir = do 92 | (dirs, files) <- listDir dir 93 | S.fromList (toFilePath <$> files) `S.serial` foldMap readDir dirs 94 | 95 | parseFiles = S.mapMaybeM $ (\path -> 96 | parseNixFileLoc path >>= \case 97 | Right parse -> do 98 | pure $ Just parse 99 | Left why -> do 100 | liftIO $ whenNormal $ log $ "Failure when parsing:\n" <> pShow why 101 | pure Nothing) 102 | 103 | pipeline (NixLinter {..}) combined = let 104 | exitLog x = S.fromEffect . liftIO . const (log x >> exitFailure) 105 | walker = if recursive 106 | then (>>= listDirRecursive) 107 | else id 108 | 109 | walk = case (recursive, null files) of 110 | (False, True) -> exitLog "No files to parse, quitting..." 111 | (True, True) -> ("." .:) >>> walker 112 | (_, _) -> walker 113 | 114 | in 115 | S.fromList files 116 | & walk 117 | & S.filter ((recursive -->) <$> isSuffixOf ".nix") 118 | & S.map (\p -> if p == "-" then "/dev/stdin" else p) 119 | & S.fromAhead . parseFiles 120 | & S.fromAhead . (S.map (combined >>> S.fromList) >>> join) 121 | 122 | extraHelp :: OffenseCategory -> IO () 123 | extraHelp cat = do 124 | log $ "-W " <> (color Blue $ style Bold $ pShow cat) <> "\n" 125 | 126 | example <- getDataFileName ("examples/" <> show cat <> ".nix") 127 | mainExample <- readFile example 128 | let indented = T.unlines $ (" " <>) <$> T.lines mainExample 129 | log $ indented <> "\n" 130 | 131 | runChecks :: NixLinter -> IO () 132 | runChecks (opts@NixLinter{..}) = do 133 | when (not $ null help_for) $ do 134 | cats <- case (getChecks [] help_for) of 135 | Left err -> do 136 | for_ err (log . T.pack) 137 | exitFailure 138 | Right xs -> pure xs 139 | traverse_ extraHelp (sortOn show cats) 140 | exitSuccess 141 | 142 | combined <- getCombined check 143 | 144 | let withOutHandle = if null out 145 | then ($ stdout) 146 | else withFile out WriteMode 147 | 148 | printer = \handle -> if 149 | | json_stream -> \w -> B.hPut handle (encode w) >> hPutStr handle "\n" 150 | | json -> B.hPutStr handle . encode 151 | | otherwise -> hPutStrLn handle . prettyOffense 152 | 153 | results = pipeline opts combined 154 | 155 | -- We can't use the naive implementation here, because that opens the files 156 | -- multiple times 157 | withOutHandle $ \handle -> do 158 | hasIssues <- newIORef False 159 | S.drain $ flip S.mapM results $ \result -> do 160 | printer handle result 161 | -- "Smuggle" the result out of the Streamly datatype 162 | writeIORef hasIssues True 163 | 164 | hadIssues <- readIORef hasIssues 165 | if hadIssues then exitFailure else exitSuccess 166 | -------------------------------------------------------------------------------- /src/Nix/Linter/Types.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE DeriveDataTypeable #-} 2 | {-# LANGUAGE DeriveFunctor #-} 3 | {-# LANGUAGE DeriveGeneric #-} 4 | {-# LANGUAGE FlexibleInstances #-} 5 | {-# LANGUAGE KindSignatures #-} 6 | {-# LANGUAGE MultiWayIf #-} 7 | {-# LANGUAGE OverloadedStrings #-} 8 | {-# LANGUAGE RecordWildCards #-} 9 | {-# LANGUAGE TypeSynonymInstances #-} 10 | 11 | module Nix.Linter.Types where 12 | 13 | import Control.Monad (join) 14 | import Data.Fix 15 | import Data.Text (Text, pack) 16 | 17 | import Data.Aeson 18 | import GHC.Generics 19 | import System.Console.CmdArgs (Data) 20 | 21 | import Nix.Expr.Types 22 | import Nix.Expr.Types.Annotated 23 | import Nix.Pretty (prettyNix) 24 | 25 | import Nix.Linter.Traversals 26 | import Nix.Linter.Utils 27 | 28 | data OffenseF a = Offense 29 | { offending :: NExprLoc 30 | , rewrite :: Maybe NExpr -- Location info isn't important here, because none of it will be accurate. 31 | , pos :: SrcSpan 32 | , notes :: [Note] 33 | , offense :: a 34 | } deriving (Functor, Show, Generic) 35 | 36 | type Offense = OffenseF OffenseCategory 37 | type Check = NExprLoc -> [Offense] 38 | 39 | instance ToJSON Offense where 40 | toJSON o@(Offense{..}) = object 41 | [ "offending" .= showNix (stripAnnotation offending) 42 | , "rewrite" .= toJSON (showNix <$> rewrite) 43 | , "pos" .= toJSON pos 44 | , "notes" .= toJSON notes 45 | , "offense" .= toJSON offense 46 | , "file" .= sourceName (spanBegin pos) 47 | , "description" .= toJSON (describe o) 48 | ] where showNix = pack . show . prettyNix 49 | 50 | data Note 51 | = IncreasesGenerality 52 | | Note Text Text deriving (Show, Generic) 53 | 54 | instance ToJSON Note where 55 | toJSONList xs = object $ toJSON <$$> convert <$> xs where 56 | convert (Note a b) = (a, Just b) 57 | convert x = (pack $ show x, Nothing) 58 | 59 | setLoc :: SourcePos -> Offense -> Offense 60 | setLoc l x = x { pos=singletonSpan l } 61 | 62 | setPos :: SrcSpan -> Offense -> Offense 63 | setPos l x = x { pos=l } 64 | 65 | setOffender :: NExprLoc -> Offense -> Offense 66 | setOffender e x = setPos (getPos e) $ x {offending=e} 67 | 68 | suggest :: NExpr -> Offense -> Offense 69 | suggest e x = x {rewrite = pure e} 70 | 71 | suggest' :: NExprLoc -> Offense -> Offense 72 | suggest' e = suggest $ stripAnnotation e 73 | 74 | note :: Note -> Offense -> Offense 75 | note n x = x {notes = n : notes x} 76 | 77 | note' :: Text -> Text -> Offense -> Offense 78 | note' a b = note $ Note a b 79 | 80 | getPos :: NExprLoc -> SrcSpan 81 | getPos = annotation . getCompose . unFix 82 | 83 | -- For ease of pattern matching 84 | type UnwrappedNExprLoc = NExprLocF (Fix NExprLocF) 85 | 86 | type CheckBase = (OffenseCategory -> Offense) -> NExprLoc -> [Offense] 87 | 88 | getSpan :: NExprLoc -> SrcSpan 89 | getSpan = annotation . getCompose . unFix 90 | 91 | check :: CheckBase -> Check 92 | check base tree = (\e -> base (Offense e Nothing (getSpan e) []) e) =<< universe tree 93 | 94 | pShow :: Show a => a -> Text 95 | pShow = pack . show 96 | 97 | prettySourcePos :: SourcePos -> Text 98 | prettySourcePos (SourcePos file l c) = pack file <> ":" <> pShow (unPos l) <> ":" <> pShow (unPos c) 99 | 100 | prettySourceSpan :: SrcSpan -> Text 101 | prettySourceSpan (SrcSpan pos1@(SourcePos f1 l1 c1) pos2@(SourcePos f2 l2 c2)) 102 | | f1 /= f2 = base <> prettySourcePos pos2 -- It could happen I guess? 103 | | l1 /= l2 = base <> pShow (unPos l2) <> ":" <> pShow (unPos c2) 104 | | c1 /= c2 = base <> pShow (unPos c2) 105 | | otherwise = prettySourcePos pos1 106 | where base = prettySourcePos pos1 <> "-" 107 | 108 | singletonSpan :: SourcePos -> SrcSpan 109 | singletonSpan = join SrcSpan 110 | 111 | getNote :: Offense -> Text -> Maybe Text 112 | getNote (Offense {..}) key = lookup key lt 113 | where lt = [ (k, v) | Note k v <- notes ] 114 | 115 | -- TODO: escape 116 | quoteVar :: Text -> Text 117 | quoteVar v = "`" <> v <> "`" 118 | 119 | describe :: Offense -> Text 120 | describe full@(Offense {..}) = let 121 | fullNote = getNote full 122 | o = offense 123 | -- TODO: extract 124 | varName = fullNote "varName" 125 | now = fullNote "now" 126 | suggested = fullNote "suggested" 127 | 128 | whyNot = fmap (pack . show . prettyNix) rewrite 129 | in case o of 130 | UnusedLetBind | Just x <- varName -> "Unused `let` bind " <> quoteVar x 131 | UnusedArg | Just x <- varName -> "Unused argument " <> quoteVar x 132 | EmptyInherit -> "Empty `inherit`" 133 | UnneededRec -> "Unneeded `rec` on set" 134 | EtaReduce | Just x <- varName -> "Possible η-reduction of argument " <> quoteVar x 135 | ListLiteralConcat -> "Concatenating two list literals" 136 | SetLiteralUpdate -> "Concatenating two set literals with `//`" 137 | UpdateEmptySet -> "Updating an empty set with `//`" 138 | NegateAtom | Just r <- whyNot -> "Negating an atom, why not " <> quoteVar r 139 | EmptyVariadicParamSet -> "Using `{ ... }` as pattern match" 140 | DIYInherit | Just x <- varName -> "Use " <> quoteVar ("inherit " <> x) 141 | SequentialLet -> "Sequential `let` blocks (`let ... in let ... in ...`)" 142 | EmptyLet -> "Empty `let` block" 143 | UnfortunateArgName | Just x <- now, Just x' <- suggested -> "Unfortunate argument name " 144 | <> quoteVar x <> " prevents the use of `inherit`, why not use " <> quoteVar x' 145 | FreeLetInFunc -> "Move `let` block outside function definition" 146 | _ -> pShow o 147 | 148 | prettyOffense :: Offense -> Text 149 | prettyOffense o@(Offense {..}) = describe o <> " at " <> prettySourceSpan pos 150 | 151 | data OffenseCategory 152 | = UnusedLetBind 153 | | UnusedArg 154 | | EmptyInherit 155 | | UnneededRec 156 | | ListLiteralConcat 157 | | SetLiteralUpdate 158 | | UpdateEmptySet 159 | | UnneededAntiquote 160 | | NegateAtom 161 | | EtaReduce 162 | | FreeLetInFunc 163 | | LetInInheritRecset 164 | | DIYInherit 165 | | EmptyLet 166 | | UnfortunateArgName 167 | | BetaReduction 168 | | AlphabeticalBindings 169 | | AlphabeticalArgs 170 | | SequentialLet 171 | | EmptyVariadicParamSet 172 | deriving (Show, Generic, Data, Ord, Eq, Bounded, Enum) 173 | 174 | instance ToJSON OffenseCategory 175 | -------------------------------------------------------------------------------- /src/Nix/Linter/Checks.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE LambdaCase #-} 2 | {-# LANGUAGE OverloadedStrings #-} 3 | {-# LANGUAGE TupleSections #-} 4 | 5 | module Nix.Linter.Checks where 6 | 7 | import Control.Arrow ((&&&)) 8 | import Data.Char (isUpper, toLower) 9 | import Data.Function ((&)) 10 | import Data.List (isInfixOf, sortOn) 11 | import Data.List.NonEmpty (NonEmpty (..)) 12 | import Data.Maybe (catMaybes, fromJust, fromMaybe, maybeToList) 13 | import Data.Ord (Down (..)) 14 | import Data.Text (Text) 15 | 16 | 17 | import qualified Data.Set as Set 18 | 19 | import Data.Fix 20 | import Data.Pair 21 | 22 | import Nix.Atoms 23 | import Nix.Expr.Shorthands 24 | import Nix.Expr.Types 25 | import Nix.Expr.Types.Annotated 26 | 27 | import Nix.Linter.Tools 28 | import Nix.Linter.Types 29 | import Nix.Linter.Utils (choose, sorted, (<$$>), (<&>)) 30 | 31 | varName :: Text 32 | varName = "varName" 33 | 34 | checkUnusedLetBinding :: CheckBase 35 | checkUnusedLetBinding warn e = [ (warn UnusedLetBind) 36 | & setLoc loc 37 | & note' varName name 38 | | NLet_ _ binds usedIn <- [unFix e] 39 | , (bind, others) <- choose binds 40 | , NamedVar (StaticKey name :| []) _ loc <- [bind] 41 | , all (noRef name) (values others) 42 | , name `noRef` usedIn 43 | ] 44 | 45 | checkUnusedArg :: CheckBase 46 | checkUnusedArg warn e = [ warn UnusedArg 47 | & setLoc begin 48 | & note' varName name 49 | | NAbs_ (SrcSpan begin _) params usedIn <- [unFix e] 50 | , let (names, siblingExprs) = case params of 51 | Param name -> ([name], []) 52 | ParamSet xs _ global -> 53 | (maybeToList global ++ (fst <$> xs), catMaybes (snd <$> xs)) 54 | , name <- names 55 | , nonIgnoredName name 56 | , all (noRef name) siblingExprs 57 | , name `noRef` usedIn 58 | ] 59 | 60 | checkEmptyInherit :: CheckBase 61 | checkEmptyInherit warn e = [ (warn EmptyInherit) {pos=singletonSpan loc} 62 | | (bindings, _, _) <- [topLevelBinds e] 63 | , Inherit _ [] loc <- bindings 64 | ] 65 | 66 | checkUnneededRec :: CheckBase 67 | checkUnneededRec warn e = [ warn UnneededRec 68 | | NSet_ _ann NRecursive binds <- [unFix e] 69 | , not $ or $ choose binds <&> \case 70 | (bind, others) -> case bind of 71 | NamedVar (StaticKey name :| []) _ _ -> all (noRef name) (values others) 72 | _ -> False 73 | ] 74 | 75 | checkOpBase :: OffenseCategory -> Pair (UnwrappedNExprLoc -> Bool) -> NBinaryOp -> Bool -> CheckBase 76 | checkOpBase ot (Pair p1 p2) op reflexive warn e = [ warn ot 77 | | NBinary_ _ op' (Fix e2) (Fix e1) <- [unFix e] 78 | , p1 e1 && p2 e2 || p1 e2 && p2 e1 && reflexive 79 | , op == op' 80 | ] 81 | 82 | checkSymmetricOpBase :: OffenseCategory -> (UnwrappedNExprLoc -> Bool) -> NBinaryOp -> CheckBase 83 | checkSymmetricOpBase ot p op = checkOpBase ot (dup p) op False 84 | 85 | checkListLiteralConcat :: CheckBase 86 | checkListLiteralConcat = checkSymmetricOpBase ListLiteralConcat isListLiteral NConcat where 87 | isListLiteral = \case 88 | NList_ _ _ -> True 89 | _ -> False 90 | 91 | checkSetLiteralUpdate :: CheckBase 92 | checkSetLiteralUpdate = checkSymmetricOpBase SetLiteralUpdate isSetLiteral NUpdate where 93 | isSetLiteral e = let (_, _, isLit) = topLevelBinds (Fix e) in isLit 94 | 95 | checkUpdateEmptySet :: CheckBase 96 | checkUpdateEmptySet = checkOpBase UpdateEmptySet (Pair (const True) isEmptySetLiteral) NUpdate True where 97 | isEmptySetLiteral e = let (xs, _, isLit) = topLevelBinds (Fix e) in null xs && isLit 98 | 99 | -- Works, but the pattern can be useful, so not in the full list of checks. 100 | checkUnneededAntiquote :: CheckBase 101 | checkUnneededAntiquote warn e = [ warn UnneededAntiquote 102 | | NStr_ _ (DoubleQuoted [Antiquoted _]) <- [unFix e] 103 | ] 104 | 105 | checkNegateAtom :: CheckBase 106 | checkNegateAtom warn e= [warn NegateAtom & suggest (mkBool $ not b) 107 | | NUnary_ _ NNot e' <- [unFix e] 108 | , NConstant_ _ (NBool b) <- [unFix e'] 109 | ] 110 | 111 | checkEtaReduce :: CheckBase 112 | checkEtaReduce warn e = [ warn EtaReduce & suggest' xs 113 | & note' varName x 114 | | NAbs_ _ (Param x) e' <- [unFix e] 115 | , NBinary_ _ NApp xs e'' <- [unFix e'] 116 | , NSym_ _ x' <- [unFix e''] 117 | , x == x' 118 | , x `noRef` xs 119 | ] 120 | 121 | checkFreeLetInFunc :: CheckBase 122 | checkFreeLetInFunc warn e = [ warn FreeLetInFunc 123 | & note' varName x 124 | & suggest (mkLets (stripAnnotation <$$> xs) $ mkFunction (Param x) $ stripAnnotation e'') 125 | | NAbs_ _ (Param x) e' <- [unFix e] 126 | , NLet_ _ xs e'' <- [unFix e'] 127 | , all (noRef x) $ values xs 128 | ] 129 | 130 | checkDIYInherit :: CheckBase 131 | checkDIYInherit warn e = [ warn DIYInherit 132 | & setLoc loc 133 | & note' varName x 134 | | (binds, _, _) <- [topLevelBinds e] 135 | , NamedVar (StaticKey x :| []) e' loc <- binds 136 | , NSym_ _ x' <- [unFix e'] 137 | , x == x' 138 | ] 139 | 140 | checkLetInInheritRecset :: CheckBase 141 | checkLetInInheritRecset warn e = [ warn LetInInheritRecset 142 | & note' varName name 143 | | NLet_ _ binds usedIn <- [unFix e] 144 | , (inner, outer) <- chooseTrees usedIn 145 | , NSet_ _ann NRecursive set <- [unFix inner] 146 | , (this, others) <- choose binds 147 | , let names = simpleBoundNames this 148 | , let allNamesFree x = all (`noRef` x) names 149 | , name <- names 150 | , plainInherits name set 151 | , all allNamesFree $ values others 152 | , allNamesFree outer 153 | ] 154 | 155 | checkEmptyLet :: CheckBase 156 | checkEmptyLet warn e = [ warn EmptyLet & suggest' e' 157 | | NLet_ _ [] e' <- [unFix e] 158 | ] 159 | 160 | checkUnfortunateArgName :: CheckBase 161 | checkUnfortunateArgName warn e = [ warn UnfortunateArgName 162 | & note' "now" name & note' "suggested" name' 163 | | NAbs_ _ (Param name) e' <- [unFix e] 164 | , (inner, outer) <- chooseTrees e' 165 | , (bindings, context, _) <- [topLevelBinds inner] 166 | , NamedVar (StaticKey name' :| []) e'' _ <- bindings 167 | , name' /= name 168 | , NSym_ _ name'' <- [unFix e''] 169 | , name'' == name 170 | 171 | , let valid = not . plainInheritsAnywhere name 172 | -- These are expensive! Do them last: 173 | , valid context 174 | , valid $ Fix $ NSet_ generated NRecursive bindings 175 | , valid outer 176 | ] 177 | 178 | checkBetaReduction :: CheckBase 179 | checkBetaReduction warn e = [ warn BetaReduction 180 | | NBinary_ _ NApp e' _ <- [unFix e] 181 | , NAbs_ _ _ _ <- [unFix e'] 182 | ] 183 | 184 | checkAlphabeticalBindings :: CheckBase 185 | checkAlphabeticalBindings warn e = [ warn AlphabeticalBindings 186 | | (bindings, _, _) <- [topLevelBinds e] 187 | , not $ sorted $ const () <$$> bindings 188 | ] 189 | 190 | checkAlphabeticalArgs :: CheckBase 191 | checkAlphabeticalArgs warn e = [ warn AlphabeticalArgs 192 | | NAbs_ _ (ParamSet xs _ _) _ <- [unFix e] 193 | , not $ sorted $ const () <$$> xs 194 | ] 195 | 196 | checkSequentialLet :: CheckBase 197 | checkSequentialLet warn e = [ warn SequentialLet 198 | | NLet_ _ _ e' <- [unFix e] 199 | , NLet_ _ _ _ <- [unFix e'] 200 | ] 201 | 202 | checkEmptyVariadicParamSet :: CheckBase 203 | checkEmptyVariadicParamSet warn e = [ warn EmptyVariadicParamSet 204 | & suggest (Fix $ NAbs (Param $ fromMaybe "_" x) $ stripAnnotation e') 205 | & note IncreasesGenerality 206 | | NAbs_ _ (ParamSet [] True x) e' <- [unFix e] 207 | ] 208 | 209 | data AvailableCheck = AvailableCheck 210 | { defaultEnabled :: Bool 211 | , category :: OffenseCategory 212 | , baseCheck :: CheckBase 213 | , description :: String 214 | } 215 | 216 | enabledCheck, disabledCheck :: OffenseCategory -> CheckBase -> String -> AvailableCheck 217 | enabledCheck = AvailableCheck True 218 | disabledCheck = AvailableCheck False 219 | 220 | checks :: [AvailableCheck] 221 | checks = sortOn (Down . defaultEnabled &&& show . category) 222 | [ enabledCheck UnusedLetBind checkUnusedLetBinding "" 223 | , enabledCheck UnusedArg checkUnusedArg "" 224 | , enabledCheck EmptyInherit checkEmptyInherit "" 225 | , enabledCheck UnneededRec checkUnneededRec "" 226 | , enabledCheck ListLiteralConcat checkListLiteralConcat "" 227 | , enabledCheck SetLiteralUpdate checkSetLiteralUpdate "" 228 | , enabledCheck UpdateEmptySet checkUpdateEmptySet "" 229 | , disabledCheck UnneededAntiquote checkUnneededAntiquote "" 230 | , enabledCheck NegateAtom checkNegateAtom "" 231 | , enabledCheck EtaReduce checkEtaReduce "" 232 | , enabledCheck FreeLetInFunc checkFreeLetInFunc "" 233 | , enabledCheck LetInInheritRecset checkLetInInheritRecset "" 234 | , enabledCheck DIYInherit checkDIYInherit "" 235 | , enabledCheck EmptyLet checkEmptyLet "" 236 | , enabledCheck UnfortunateArgName checkUnfortunateArgName "" 237 | , disabledCheck BetaReduction checkBetaReduction "" 238 | , disabledCheck AlphabeticalBindings checkAlphabeticalBindings "" 239 | , disabledCheck AlphabeticalArgs checkAlphabeticalArgs "" 240 | , enabledCheck SequentialLet checkSequentialLet "" 241 | , disabledCheck EmptyVariadicParamSet checkEmptyVariadicParamSet "" 242 | ] 243 | 244 | multiChecks :: [(String, Set.Set OffenseCategory)] 245 | multiChecks = Set.fromList <$$> 246 | [ ("All", category <$> checks) 247 | , ("Default", category <$> filter defaultEnabled checks) 248 | , mkMulti "Alphabetical" 249 | , mkMulti "Unused" 250 | ] where 251 | mkMulti s = (toLower <$> s, filter (isInfixOf s . show) $ category <$> checks) 252 | 253 | combineChecks :: [CheckBase] -> Check 254 | combineChecks c e = (check <$> c) >>= ($ e) 255 | 256 | parseCheckArg :: String -> Either String (Set.Set OffenseCategory -> Set.Set OffenseCategory) 257 | parseCheckArg arg = case filter ((fmap toLower arg ==) . fst) lookupTable of 258 | [] -> Left $ "No parse: " ++ arg 259 | [(_, x)] -> Right $ x 260 | _ -> Left $ "Ambiguous parse: " ++ arg 261 | where 262 | sets = ((show &&& Set.singleton) <$> category <$> checks) ++ multiChecks 263 | names = conversions =<< sets 264 | conversions (name, x) = (,x) <$> (fmap toLower <$> ([id, filter isUpper] <*> [name])) 265 | lookupTable = do 266 | (name, s) <- names 267 | (prefix, f) <- [("", Set.union), ("no-", Set.difference)] 268 | pure (prefix ++ name, flip f s) 269 | 270 | checkCategories :: [OffenseCategory] -> Check 271 | checkCategories enabled = let 272 | lookupTable = (category &&& baseCheck) <$> checks 273 | getCheck = flip lookup lookupTable 274 | -- fromJust, because we _want_ to crash when an unknown check shows up, 275 | -- because that's certainly a bug! 276 | checks' = fromJust <$> (getCheck <$> enabled) 277 | in combineChecks checks' 278 | --------------------------------------------------------------------------------