├── test ├── Spec.hs └── Unused │ ├── ProjectionSpec.hs │ ├── Cache │ └── FindArgsFromIgnoredPathsSpec.hs │ ├── AliasesSpec.hs │ ├── TypesSpec.hs │ ├── Grouping │ └── InternalSpec.hs │ ├── UtilSpec.hs │ ├── TermSearch │ └── InternalSpec.hs │ ├── LikelihoodCalculatorSpec.hs │ ├── ParserSpec.hs │ └── ResponseFilterSpec.hs ├── Setup.hs ├── .gitignore ├── src ├── Common.hs └── Unused │ ├── CLI.hs │ ├── ResultsClassifier.hs │ ├── Regex.hs │ ├── CLI │ ├── Views │ │ ├── NoResultsFound.hs │ │ ├── Error.hs │ │ ├── AnalysisHeader.hs │ │ ├── GitSHAsHeader.hs │ │ ├── SearchResult │ │ │ ├── Internal.hs │ │ │ ├── Types.hs │ │ │ ├── ColumnFormatter.hs │ │ │ ├── TableResult.hs │ │ │ └── ListResult.hs │ │ ├── FingerprintError.hs │ │ ├── InvalidConfigError.hs │ │ ├── MissingTagsFileError.hs │ │ └── SearchResult.hs │ ├── Views.hs │ ├── ProgressIndicator │ │ ├── Types.hs │ │ └── Internal.hs │ ├── GitContext.hs │ ├── Search.hs │ ├── ProgressIndicator.hs │ └── Util.hs │ ├── TermSearch │ ├── Types.hs │ └── Internal.hs │ ├── Grouping │ ├── Internal.hs │ └── Types.hs │ ├── Parser.hs │ ├── Projection │ └── Transform.hs │ ├── Grouping.hs │ ├── TermSearch.hs │ ├── Util.hs │ ├── TagsSource.hs │ ├── Cache │ ├── FindArgsFromIgnoredPaths.hs │ └── DirectoryFingerprint.hs │ ├── Aliases.hs │ ├── GitContext.hs │ ├── Projection.hs │ ├── ResultsClassifier │ ├── Config.hs │ └── Types.hs │ ├── LikelihoodCalculator.hs │ ├── Cache.hs │ ├── ResponseFilter.hs │ └── Types.hs ├── .travis.yml ├── .circleci └── config.yml ├── Dockerfile ├── LICENSE ├── stack.yaml ├── app ├── Types.hs ├── Main.hs └── App.hs ├── data └── config.yml ├── NEWS ├── unused.cabal └── README.md /test/Spec.hs: -------------------------------------------------------------------------------- 1 | {-# OPTIONS_GHC -F -pgmF hspec-discover #-} 2 | -------------------------------------------------------------------------------- /Setup.hs: -------------------------------------------------------------------------------- 1 | import Distribution.Simple 2 | 3 | main = defaultMain 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .stack-work 2 | tmp 3 | dist-newstyle/ 4 | stack.yaml.lock 5 | -------------------------------------------------------------------------------- /src/Common.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE CPP #-} 2 | 3 | module Common 4 | ( (<>) 5 | ) where 6 | #if MIN_VERSION_base(4, 8, 0) 7 | import Data.Monoid ((<>)) 8 | #endif 9 | -------------------------------------------------------------------------------- /src/Unused/CLI.hs: -------------------------------------------------------------------------------- 1 | module Unused.CLI 2 | ( module X 3 | ) where 4 | 5 | import Unused.CLI.GitContext as X 6 | import Unused.CLI.Search as X 7 | import Unused.CLI.Util as X 8 | -------------------------------------------------------------------------------- /src/Unused/ResultsClassifier.hs: -------------------------------------------------------------------------------- 1 | module Unused.ResultsClassifier 2 | ( module X 3 | ) where 4 | 5 | import Unused.ResultsClassifier.Config as X 6 | import Unused.ResultsClassifier.Types as X 7 | -------------------------------------------------------------------------------- /src/Unused/Regex.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE FlexibleContexts #-} 2 | 3 | module Unused.Regex 4 | ( matchRegex 5 | ) where 6 | 7 | import Text.Regex.TDFA 8 | 9 | matchRegex :: String -> String -> Bool 10 | matchRegex = matchTest . stringToRegex 11 | 12 | stringToRegex :: String -> Regex 13 | stringToRegex = makeRegex 14 | -------------------------------------------------------------------------------- /src/Unused/CLI/Views/NoResultsFound.hs: -------------------------------------------------------------------------------- 1 | module Unused.CLI.Views.NoResultsFound 2 | ( noResultsFound 3 | ) where 4 | 5 | import Unused.CLI.Util 6 | 7 | noResultsFound :: IO () 8 | noResultsFound = do 9 | setSGR [SetColor Foreground Dull Green] 10 | setSGR [SetConsoleIntensity BoldIntensity] 11 | putStrLn "Unused found no results" 12 | setSGR [Reset] 13 | -------------------------------------------------------------------------------- /src/Unused/TermSearch/Types.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE GeneralizedNewtypeDeriving #-} 2 | 3 | module Unused.TermSearch.Types 4 | ( SearchResults(..) 5 | , SearchBackend(..) 6 | ) where 7 | 8 | import Unused.Types (TermMatch) 9 | 10 | data SearchBackend 11 | = Ag 12 | | Rg 13 | 14 | newtype SearchResults = SearchResults 15 | { fromResults :: [TermMatch] 16 | } deriving (Semigroup, Monoid) 17 | -------------------------------------------------------------------------------- /src/Unused/CLI/Views/Error.hs: -------------------------------------------------------------------------------- 1 | module Unused.CLI.Views.Error 2 | ( errorHeader 3 | ) where 4 | 5 | import Unused.CLI.Util 6 | 7 | errorHeader :: String -> IO () 8 | errorHeader s = do 9 | setSGR [SetColor Background Vivid Red] 10 | setSGR [SetColor Foreground Vivid White] 11 | setSGR [SetConsoleIntensity BoldIntensity] 12 | 13 | putStrLn $ "\n" ++ s ++ "\n" 14 | 15 | setSGR [Reset] 16 | -------------------------------------------------------------------------------- /src/Unused/CLI/Views.hs: -------------------------------------------------------------------------------- 1 | module Unused.CLI.Views 2 | ( module X 3 | ) where 4 | 5 | import Unused.CLI.Views.AnalysisHeader as X 6 | import Unused.CLI.Views.FingerprintError as X 7 | import Unused.CLI.Views.GitSHAsHeader as X 8 | import Unused.CLI.Views.InvalidConfigError as X 9 | import Unused.CLI.Views.MissingTagsFileError as X 10 | import Unused.CLI.Views.NoResultsFound as X 11 | import Unused.CLI.Views.SearchResult as X 12 | -------------------------------------------------------------------------------- /src/Unused/CLI/Views/AnalysisHeader.hs: -------------------------------------------------------------------------------- 1 | module Unused.CLI.Views.AnalysisHeader 2 | ( analysisHeader 3 | ) where 4 | 5 | import Unused.CLI.Util 6 | 7 | analysisHeader :: [a] -> IO () 8 | analysisHeader terms = do 9 | setSGR [SetConsoleIntensity BoldIntensity] 10 | putStr "Unused: " 11 | setSGR [Reset] 12 | 13 | putStr "analyzing " 14 | 15 | setSGR [SetColor Foreground Dull Green] 16 | putStr $ show $ length terms 17 | setSGR [Reset] 18 | putStr " terms" 19 | -------------------------------------------------------------------------------- /src/Unused/CLI/Views/GitSHAsHeader.hs: -------------------------------------------------------------------------------- 1 | module Unused.CLI.Views.GitSHAsHeader 2 | ( loadingSHAsHeader 3 | ) where 4 | 5 | import Unused.CLI.Util 6 | 7 | loadingSHAsHeader :: Int -> IO () 8 | loadingSHAsHeader commitCount = do 9 | setSGR [SetConsoleIntensity BoldIntensity] 10 | putStr "Unused: " 11 | setSGR [Reset] 12 | 13 | putStr "loading the most recent " 14 | 15 | setSGR [SetColor Foreground Dull Green] 16 | putStr $ show commitCount 17 | setSGR [Reset] 18 | putStr " SHAs from git" 19 | -------------------------------------------------------------------------------- /src/Unused/CLI/ProgressIndicator/Types.hs: -------------------------------------------------------------------------------- 1 | module Unused.CLI.ProgressIndicator.Types 2 | ( ProgressIndicator(..) 3 | ) where 4 | 5 | import qualified Control.Concurrent as CC 6 | import qualified System.Console.ANSI as ANSI 7 | import qualified System.ProgressBar as PB 8 | 9 | data ProgressIndicator 10 | = Spinner { sSnapshots :: [String] 11 | , sLength :: Int 12 | , sDelay :: Int 13 | , sColors :: [ANSI.Color] 14 | , sThreadId :: Maybe CC.ThreadId } 15 | | ProgressBar { pbProgressRef :: Maybe PB.ProgressRef 16 | , pbThreadId :: Maybe CC.ThreadId } 17 | -------------------------------------------------------------------------------- /src/Unused/CLI/Views/SearchResult/Internal.hs: -------------------------------------------------------------------------------- 1 | module Unused.CLI.Views.SearchResult.Internal 2 | ( termColor 3 | , removalReason 4 | ) where 5 | 6 | import Unused.CLI.Util (Color(..)) 7 | import Unused.Types (TermResults(..), Removal(..), RemovalLikelihood(..)) 8 | 9 | termColor :: TermResults -> Color 10 | termColor = likelihoodColor . rLikelihood . trRemoval 11 | 12 | removalReason :: TermResults -> String 13 | removalReason = rReason . trRemoval 14 | 15 | likelihoodColor :: RemovalLikelihood -> Color 16 | likelihoodColor High = Red 17 | likelihoodColor Medium = Yellow 18 | likelihoodColor Low = Green 19 | likelihoodColor Unknown = Black 20 | likelihoodColor NotCalculated = Magenta 21 | -------------------------------------------------------------------------------- /src/Unused/CLI/GitContext.hs: -------------------------------------------------------------------------------- 1 | module Unused.CLI.GitContext 2 | ( loadGitContext 3 | ) where 4 | 5 | import qualified Data.Map.Strict as Map 6 | import Unused.CLI.ProgressIndicator 7 | (createProgressBar, progressWithIndicator) 8 | import qualified Unused.CLI.Util as U 9 | import qualified Unused.CLI.Views as V 10 | import Unused.GitContext (gitContextForResults) 11 | import Unused.Types (TermMatchSet) 12 | 13 | loadGitContext :: Int -> TermMatchSet -> IO TermMatchSet 14 | loadGitContext i tms = do 15 | U.resetScreen 16 | V.loadingSHAsHeader i 17 | Map.fromList <$> progressWithIndicator (gitContextForResults i) createProgressBar listTerms 18 | where 19 | listTerms = Map.toList tms 20 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: haskell 2 | 3 | sudo: false 4 | cache: 5 | directories: 6 | - $HOME/.stack/ 7 | 8 | matrix: 9 | include: 10 | - env: CABALVER=1.22 GHCVER=7.10.3 11 | addons: {apt: {packages: [cabal-install-1.22,ghc-7.10.3],sources: [hvr-ghc]}} 12 | 13 | before_install: 14 | - mkdir -p ~/.local/bin 15 | - export PATH=~/.local/bin:$PATH 16 | - travis_retry curl -L https://www.stackage.org/stack/linux-x86_64 | tar -xzO --wildcards '*/stack' > ~/.local/bin/stack 17 | - chmod a+x ~/.local/bin/stack 18 | 19 | install: 20 | - stack -j 2 setup --no-terminal 21 | - stack -j 2 build --only-snapshot --no-terminal 22 | 23 | script: 24 | - stack -j 2 build --no-terminal 25 | - stack test --no-terminal 26 | -------------------------------------------------------------------------------- /src/Unused/Grouping/Internal.hs: -------------------------------------------------------------------------------- 1 | module Unused.Grouping.Internal 2 | ( groupFilter 3 | ) where 4 | 5 | import qualified Data.List as L 6 | import qualified System.FilePath as FP 7 | import Unused.Grouping.Types 8 | (CurrentGrouping(..), GroupFilter, Grouping(..)) 9 | import qualified Unused.Types as T 10 | 11 | groupFilter :: CurrentGrouping -> GroupFilter 12 | groupFilter GroupByDirectory = ByDirectory . shortenedDirectory . T.tmPath 13 | groupFilter GroupByTerm = ByTerm . T.tmTerm 14 | groupFilter GroupByFile = ByFile . T.tmPath 15 | groupFilter NoGroup = const NoGrouping 16 | 17 | shortenedDirectory :: String -> String 18 | shortenedDirectory = L.intercalate "/" . take 2 . FP.splitDirectories . FP.takeDirectory 19 | -------------------------------------------------------------------------------- /src/Unused/CLI/Views/FingerprintError.hs: -------------------------------------------------------------------------------- 1 | module Unused.CLI.Views.FingerprintError 2 | ( fingerprintError 3 | ) where 4 | 5 | import qualified Data.List as L 6 | import qualified Unused.CLI.Views.Error as V 7 | import Unused.Cache.DirectoryFingerprint (FingerprintOutcome(..)) 8 | 9 | fingerprintError :: FingerprintOutcome -> IO () 10 | fingerprintError e = do 11 | V.errorHeader "There was a problem generating a cache fingerprint:" 12 | 13 | printOutcomeMessage e 14 | 15 | printOutcomeMessage :: FingerprintOutcome -> IO () 16 | printOutcomeMessage (MD5ExecutableNotFound execs) = 17 | putStrLn $ 18 | "Unable to find any of the following executables \ 19 | \in your PATH: " ++ L.intercalate ", " execs 20 | -------------------------------------------------------------------------------- /src/Unused/CLI/Views/InvalidConfigError.hs: -------------------------------------------------------------------------------- 1 | module Unused.CLI.Views.InvalidConfigError 2 | ( invalidConfigError 3 | ) where 4 | 5 | import Unused.CLI.Util 6 | import qualified Unused.CLI.Views.Error as V 7 | import Unused.ResultsClassifier (ParseConfigError(..)) 8 | 9 | invalidConfigError :: [ParseConfigError] -> IO () 10 | invalidConfigError es = do 11 | V.errorHeader "There was a problem with the following config file(s):" 12 | mapM_ configError es 13 | setSGR [Reset] 14 | 15 | configError :: ParseConfigError -> IO () 16 | configError ParseConfigError {pcePath = path, pceParseError = msg} = do 17 | setSGR [SetConsoleIntensity BoldIntensity] 18 | putStrLn path 19 | setSGR [Reset] 20 | putStrLn $ " " ++ msg 21 | -------------------------------------------------------------------------------- /src/Unused/Grouping/Types.hs: -------------------------------------------------------------------------------- 1 | module Unused.Grouping.Types 2 | ( Grouping(..) 3 | , CurrentGrouping(..) 4 | , GroupedTerms 5 | , GroupFilter 6 | ) where 7 | 8 | import Unused.Types (TermMatch, TermMatchSet) 9 | 10 | data Grouping 11 | = ByDirectory String 12 | | ByTerm String 13 | | ByFile String 14 | | NoGrouping 15 | deriving (Eq, Ord) 16 | 17 | data CurrentGrouping 18 | = GroupByDirectory 19 | | GroupByTerm 20 | | GroupByFile 21 | | NoGroup 22 | 23 | type GroupedTerms = (Grouping, TermMatchSet) 24 | 25 | type GroupFilter = TermMatch -> Grouping 26 | 27 | instance Show Grouping where 28 | show (ByDirectory s) = s 29 | show (ByTerm s) = s 30 | show (ByFile s) = s 31 | show NoGrouping = "" 32 | -------------------------------------------------------------------------------- /src/Unused/CLI/Views/SearchResult/Types.hs: -------------------------------------------------------------------------------- 1 | module Unused.CLI.Views.SearchResult.Types 2 | ( ResultsOptions(..) 3 | , ResultsFormat(..) 4 | , ResultsPrinter 5 | , ColumnFormat(..) 6 | , columnFormat 7 | , outputFormat 8 | , R.runReaderT 9 | , R.liftIO 10 | ) where 11 | 12 | import qualified Control.Monad.Reader as R 13 | import Unused.CLI.Views.SearchResult.ColumnFormatter 14 | 15 | data ResultsOptions = ResultsOptions 16 | { roColumnFormat :: ColumnFormat 17 | , roOutputFormat :: ResultsFormat 18 | } 19 | 20 | data ResultsFormat = Column | List 21 | type ResultsPrinter = R.ReaderT ResultsOptions IO 22 | 23 | columnFormat :: ResultsPrinter ColumnFormat 24 | columnFormat = roColumnFormat <$> R.ask 25 | 26 | outputFormat :: ResultsPrinter ResultsFormat 27 | outputFormat = roOutputFormat <$> R.ask 28 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | docker: 5 | - image: romanandreg/stack:latest 6 | steps: 7 | - checkout 8 | - run: stack upgrade 9 | - restore_cache: 10 | keys: 11 | - stack-{{ checksum "stack.yaml" }} 12 | - restore_cache: 13 | keys: 14 | - stack-{{ checksum "stack.yaml" }}-{{ checksum "unused.cabal" }} 15 | - run: 16 | name: Configure Stack 17 | command: stack -j 2 setup --no-terminal 18 | - run: 19 | name: Build Stack 20 | command: stack -j 2 build --only-snapshot --fast --no-terminal 21 | - save_cache: 22 | key: stack-{{ checksum "stack.yaml" }} 23 | paths: 24 | - “/root/.stack” 25 | - save_cache: 26 | key: stack-{{ checksum "stack.yaml" }}-{{ checksum "unused.cabal" }} 27 | paths: 28 | - “.stack-work” 29 | - run: stack test 30 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM haskell:8.0.1 2 | 3 | ENV AG_VERSION 0.33.0 4 | 5 | RUN DEBIAN_FRONTEND=noninteractive apt-get update && apt-get install -y \ 6 | wget \ 7 | automake \ 8 | pkg-config \ 9 | libpcre3-dev \ 10 | zlib1g-dev \ 11 | liblzma-dev && \ 12 | rm -rf /var/lib/apt/lists/* 13 | 14 | RUN gpg --keyserver keyserver.ubuntu.com --recv 3F0A04B6 && \ 15 | wget http://geoff.greer.fm/ag/releases/the_silver_searcher-$AG_VERSION.tar.gz -O /tmp/ag.tar.gz && \ 16 | wget http://geoff.greer.fm/ag/releases/the_silver_searcher-$AG_VERSION.tar.gz.asc -O /tmp/ag.tar.gz.asc && \ 17 | gpg --verify /tmp/ag.tar.gz.asc && \ 18 | tar --directory /tmp -xvzf /tmp/ag.tar.gz && \ 19 | cd /tmp/the_silver_searcher* && ./configure && make && make install && \ 20 | rm -r /tmp/* 21 | 22 | COPY . /app 23 | 24 | WORKDIR /app 25 | RUN stack setup && stack install 26 | 27 | WORKDIR /code 28 | -------------------------------------------------------------------------------- /src/Unused/CLI/Views/SearchResult/ColumnFormatter.hs: -------------------------------------------------------------------------------- 1 | module Unused.CLI.Views.SearchResult.ColumnFormatter 2 | ( ColumnFormat(..) 3 | , buildColumnFormatter 4 | ) where 5 | 6 | import Text.Printf (printf) 7 | import Unused.Types (TermResults(..), TermMatch(..)) 8 | 9 | data ColumnFormat = ColumnFormat 10 | { cfPrintTerm :: String -> String 11 | , cfPrintPath :: String -> String 12 | } 13 | 14 | buildColumnFormatter :: [TermResults] -> ColumnFormat 15 | buildColumnFormatter r = 16 | ColumnFormat (printf $ termFormat r) (printf $ pathFormat r) 17 | 18 | termFormat :: [TermResults] -> String 19 | termFormat rs = 20 | "%-" ++ show termWidth ++ "s" 21 | where 22 | termWidth = maximum $ termLength =<< trMatches =<< rs 23 | termLength = return . length . tmTerm 24 | 25 | pathFormat :: [TermResults] -> String 26 | pathFormat rs = 27 | "%-" ++ show pathWidth ++ "s" 28 | where 29 | pathWidth = maximum $ pathLength =<< trMatches =<< rs 30 | pathLength = return . length . tmPath 31 | -------------------------------------------------------------------------------- /test/Unused/ProjectionSpec.hs: -------------------------------------------------------------------------------- 1 | module Unused.ProjectionSpec where 2 | 3 | import Data.Text (Text) 4 | import Test.Hspec 5 | import Unused.Projection 6 | 7 | main :: IO () 8 | main = hspec spec 9 | 10 | spec :: Spec 11 | spec = 12 | parallel $ 13 | describe "translate" $ do 14 | it "replaces the text without transforms" $ translate' "foo_{}" "bar" `shouldBe` "foo_bar" 15 | it "handles text transformations" $ do 16 | translate' "{camelcase}Validator" "proper_email" `shouldBe` "ProperEmailValidator" 17 | translate' "{snakecase}" "ProperEmail" `shouldBe` "proper_email" 18 | translate' "{camelcase}Validator" "AlreadyCamelcase" `shouldBe` 19 | "AlreadyCamelcaseValidator" 20 | it "handles unknown transformations" $ 21 | translate' "{unknown}Validator" "proper_email" `shouldBe` "proper_email" 22 | 23 | translate' :: Text -> Text -> Text 24 | translate' template v = 25 | case translate template of 26 | Right f -> f v 27 | Left _ -> v 28 | -------------------------------------------------------------------------------- /src/Unused/CLI/Search.hs: -------------------------------------------------------------------------------- 1 | module Unused.CLI.Search 2 | ( SearchRunner(..) 3 | , renderHeader 4 | , executeSearch 5 | ) where 6 | 7 | import qualified Unused.CLI.ProgressIndicator as I 8 | import qualified Unused.CLI.Util as U 9 | import qualified Unused.CLI.Views as V 10 | import qualified Unused.TermSearch as TS 11 | 12 | data SearchRunner 13 | = SearchWithProgress 14 | | SearchWithoutProgress 15 | 16 | renderHeader :: [a] -> IO () 17 | renderHeader terms = do 18 | U.resetScreen 19 | V.analysisHeader terms 20 | 21 | executeSearch :: TS.SearchBackend -> SearchRunner -> [TS.SearchTerm] -> IO TS.SearchResults 22 | executeSearch backend runner terms = do 23 | renderHeader terms 24 | runSearch backend runner terms <* U.resetScreen 25 | 26 | runSearch :: TS.SearchBackend -> SearchRunner -> [TS.SearchTerm] -> IO TS.SearchResults 27 | runSearch b SearchWithProgress = I.progressWithIndicator (TS.search b) I.createProgressBar 28 | runSearch b SearchWithoutProgress = I.progressWithIndicator (TS.search b) I.createSpinner 29 | -------------------------------------------------------------------------------- /src/Unused/Parser.hs: -------------------------------------------------------------------------------- 1 | module Unused.Parser 2 | ( parseResults 3 | ) where 4 | 5 | import Control.Arrow ((&&&)) 6 | import qualified Data.Bifunctor as BF 7 | import qualified Data.List as L 8 | import qualified Data.Map.Strict as Map 9 | import Unused.Aliases (groupedTermsAndAliases) 10 | import Unused.LikelihoodCalculator (calculateLikelihood) 11 | import Unused.ResultsClassifier.Types (LanguageConfiguration(..)) 12 | import Unused.TermSearch (SearchResults, fromResults) 13 | import Unused.Types 14 | (TermMatch, TermMatchSet, resultsFromMatches, tmDisplayTerm) 15 | 16 | parseResults :: [LanguageConfiguration] -> SearchResults -> TermMatchSet 17 | parseResults lcs = 18 | Map.fromList . 19 | map (BF.second $ calculateLikelihood lcs . resultsFromMatches) . groupResults . fromResults 20 | 21 | groupResults :: [TermMatch] -> [(String, [TermMatch])] 22 | groupResults ms = map (toKey &&& id) groupedMatches 23 | where 24 | toKey = L.intercalate "|" . L.nub . L.sort . map tmDisplayTerm 25 | groupedMatches = groupedTermsAndAliases ms 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016-2018 Josh Clayton 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /src/Unused/Projection/Transform.hs: -------------------------------------------------------------------------------- 1 | module Unused.Projection.Transform 2 | ( Transform(..) 3 | , runTransformations 4 | ) where 5 | 6 | import Data.Either (rights) 7 | import Data.Text (Text) 8 | import qualified Text.Inflections as I 9 | import qualified Unused.Util as U 10 | 11 | data Transform 12 | = Camelcase 13 | | Snakecase 14 | | Noop 15 | 16 | runTransformations :: Text -> [Transform] -> Text 17 | runTransformations = foldl (flip runTransformation) 18 | 19 | runTransformation :: Transform -> Text -> Text 20 | runTransformation Camelcase = toCamelcase 21 | runTransformation Snakecase = toSnakecase 22 | runTransformation Noop = id 23 | 24 | toCamelcase :: Text -> Text 25 | toCamelcase t = maybe t I.camelize $ toMaybeWords t 26 | 27 | toSnakecase :: Text -> Text 28 | toSnakecase t = maybe t I.underscore $ toMaybeWords t 29 | 30 | toMaybeWords :: Text -> Maybe [I.SomeWord] 31 | toMaybeWords t = 32 | U.safeHead $ rights [asCamel, asSnake] 33 | where 34 | asCamel = I.parseCamelCase [] t 35 | asSnake = I.parseSnakeCase [] t 36 | -------------------------------------------------------------------------------- /test/Unused/Cache/FindArgsFromIgnoredPathsSpec.hs: -------------------------------------------------------------------------------- 1 | module Unused.Cache.FindArgsFromIgnoredPathsSpec 2 | ( main 3 | , spec 4 | ) where 5 | 6 | import Test.Hspec 7 | import Unused.Cache.FindArgsFromIgnoredPaths 8 | 9 | main :: IO () 10 | main = hspec spec 11 | 12 | spec :: Spec 13 | spec = 14 | parallel $ 15 | describe "findArgs" $ do 16 | it "converts paths" $ 17 | findArgs ["a/*", "/b/*", "c/"] `shouldBe` 18 | ["-not", "-path", "*/a/*", "-not", "-path", "*/b/*", "-not", "-path", "*/c/*"] 19 | it "converts wildcards" $ 20 | findArgs ["a/*.csv", "/b/*.csv"] `shouldBe` 21 | ["-not", "-path", "*/a/*.csv", "-not", "-path", "*/b/*.csv"] 22 | it "filenames and paths at the same time" $ 23 | findArgs ["/.foreman", ".bundle/"] `shouldBe` 24 | [ "-not" 25 | , "-name" 26 | , "*/.foreman" 27 | , "-not" 28 | , "-path" 29 | , "*/.foreman/*" 30 | , "-not" 31 | , "-path" 32 | , "*/.bundle/*" 33 | ] 34 | -------------------------------------------------------------------------------- /test/Unused/AliasesSpec.hs: -------------------------------------------------------------------------------- 1 | module Unused.AliasesSpec where 2 | 3 | import Data.Monoid ((<>)) 4 | import Test.Hspec 5 | import Unused.Aliases 6 | import Unused.ResultsClassifier.Types (TermAlias(..)) 7 | import Unused.Types (SearchTerm(..)) 8 | 9 | main :: IO () 10 | main = hspec spec 11 | 12 | spec :: Spec 13 | spec = 14 | parallel $ 15 | describe "termsAndAliases" $ do 16 | it "returns the terms if no aliases are provided" $ 17 | termsAndAliases [] ["method_1", "method_2"] `shouldBe` 18 | [OriginalTerm "method_1", OriginalTerm "method_2"] 19 | it "adds aliases to the list of terms" $ do 20 | let predicateAlias = TermAlias "*?" "be_{}" ("be_" <>) 21 | let pluralizeAlias = TermAlias "really_*" "very_{}" ("very_" <>) 22 | termsAndAliases [predicateAlias, pluralizeAlias] ["awesome?", "really_cool"] `shouldBe` 23 | [ OriginalTerm "awesome?" 24 | , AliasTerm "awesome?" "be_awesome" 25 | , OriginalTerm "really_cool" 26 | , AliasTerm "really_cool" "very_cool" 27 | ] 28 | -------------------------------------------------------------------------------- /src/Unused/CLI/Views/SearchResult/TableResult.hs: -------------------------------------------------------------------------------- 1 | module Unused.CLI.Views.SearchResult.TableResult 2 | ( printTable 3 | ) where 4 | 5 | import Control.Monad (forM_) 6 | import Unused.CLI.Util 7 | import qualified Unused.CLI.Views.SearchResult.Internal as SR 8 | import qualified Unused.CLI.Views.SearchResult.Types as SR 9 | import Unused.Types (TermResults, TermMatch(..), tmDisplayTerm) 10 | 11 | printTable :: TermResults -> [TermMatch] -> SR.ResultsPrinter () 12 | printTable r ms = do 13 | cf <- SR.columnFormat 14 | let printTerm = SR.cfPrintTerm cf 15 | let printPath = SR.cfPrintPath cf 16 | 17 | SR.liftIO $ forM_ ms $ \m -> do 18 | setSGR [SetColor Foreground Dull (SR.termColor r)] 19 | setSGR [SetConsoleIntensity NormalIntensity] 20 | putStr $ " " ++ printTerm (tmDisplayTerm m) 21 | setSGR [Reset] 22 | 23 | setSGR [SetColor Foreground Dull Cyan] 24 | setSGR [SetConsoleIntensity FaintIntensity] 25 | putStr $ " " ++ printPath (tmPath m) 26 | setSGR [Reset] 27 | 28 | putStr $ " " ++ SR.removalReason r 29 | putStr "\n" 30 | -------------------------------------------------------------------------------- /test/Unused/TypesSpec.hs: -------------------------------------------------------------------------------- 1 | module Unused.TypesSpec where 2 | 3 | import Test.Hspec 4 | import Unused.Types 5 | 6 | main :: IO () 7 | main = hspec spec 8 | 9 | spec :: Spec 10 | spec = 11 | parallel $ 12 | describe "resultsFromMatches" $ 13 | it "batches files together to calculate information" $ do 14 | let matches = 15 | [ TermMatch 16 | "ApplicationController" 17 | "app/controllers/application_controller.rb" 18 | Nothing 19 | 1 20 | , TermMatch 21 | "ApplicationController" 22 | "spec/controllers/application_controller_spec.rb" 23 | Nothing 24 | 10 25 | ] 26 | resultsFromMatches matches `shouldBe` 27 | TermResults 28 | "ApplicationController" 29 | ["ApplicationController"] 30 | matches 31 | (Occurrences 1 10) 32 | (Occurrences 1 1) 33 | (Occurrences 2 11) 34 | (Removal NotCalculated "Likelihood not calculated") 35 | Nothing 36 | -------------------------------------------------------------------------------- /test/Unused/Grouping/InternalSpec.hs: -------------------------------------------------------------------------------- 1 | module Unused.Grouping.InternalSpec 2 | ( main 3 | , spec 4 | ) where 5 | 6 | import Test.Hspec 7 | import Unused.Grouping.Internal 8 | import Unused.Grouping.Types 9 | import Unused.Types 10 | 11 | main :: IO () 12 | main = hspec spec 13 | 14 | spec :: Spec 15 | spec = 16 | parallel $ 17 | describe "groupFilter" $ do 18 | it "groups by directory" $ do 19 | let termMatch = TermMatch "AwesomeClass" "foo/bar/baz/buzz.rb" Nothing 10 20 | groupFilter GroupByDirectory termMatch `shouldBe` ByDirectory "foo/bar" 21 | it "groups by term" $ do 22 | let termMatch = TermMatch "AwesomeClass" "foo/bar/baz/buzz.rb" Nothing 10 23 | groupFilter GroupByTerm termMatch `shouldBe` ByTerm "AwesomeClass" 24 | it "groups by file" $ do 25 | let termMatch = TermMatch "AwesomeClass" "foo/bar/baz/buzz.rb" Nothing 10 26 | groupFilter GroupByFile termMatch `shouldBe` ByFile "foo/bar/baz/buzz.rb" 27 | it "groups by nothing" $ do 28 | let termMatch = TermMatch "AwesomeClass" "foo/bar/baz/buzz.rb" Nothing 10 29 | groupFilter NoGroup termMatch `shouldBe` NoGrouping 30 | -------------------------------------------------------------------------------- /src/Unused/Grouping.hs: -------------------------------------------------------------------------------- 1 | module Unused.Grouping 2 | ( Grouping(..) 3 | , CurrentGrouping(..) 4 | , GroupedTerms 5 | , groupedResponses 6 | ) where 7 | 8 | import qualified Data.List as L 9 | import qualified Data.Map.Strict as Map 10 | import Unused.Grouping.Internal (groupFilter) 11 | import Unused.Grouping.Types (Grouping(..), CurrentGrouping(..), GroupFilter, GroupedTerms) 12 | import Unused.ResponseFilter (updateMatches) 13 | import Unused.Types (TermMatchSet, TermResults(trMatches)) 14 | 15 | groupedResponses :: CurrentGrouping -> TermMatchSet -> [GroupedTerms] 16 | groupedResponses g tms = 17 | (\g' -> (g', groupedMatchSetSubsets currentGroup g' tms)) <$> groupingsFromSet 18 | where 19 | groupingsFromSet = allGroupings currentGroup tms 20 | currentGroup = groupFilter g 21 | 22 | groupedMatchSetSubsets :: GroupFilter -> Grouping -> TermMatchSet -> TermMatchSet 23 | groupedMatchSetSubsets f tms = 24 | updateMatches $ filter ((== tms) . f) 25 | 26 | allGroupings :: GroupFilter -> TermMatchSet -> [Grouping] 27 | allGroupings f = 28 | uniqueValues . Map.map (fmap f . trMatches) 29 | where 30 | uniqueValues = L.sort . L.nub . concat . Map.elems 31 | -------------------------------------------------------------------------------- /src/Unused/CLI/ProgressIndicator.hs: -------------------------------------------------------------------------------- 1 | module Unused.CLI.ProgressIndicator 2 | ( I.ProgressIndicator 3 | , createProgressBar 4 | , createSpinner 5 | , progressWithIndicator 6 | ) where 7 | 8 | import qualified Control.Concurrent.ParallelIO as PIO 9 | import qualified Unused.CLI.ProgressIndicator.Internal as I 10 | import qualified Unused.CLI.ProgressIndicator.Types as I 11 | import Unused.CLI.Util (Color(..), installChildInterruptHandler) 12 | 13 | createProgressBar :: I.ProgressIndicator 14 | createProgressBar = I.ProgressBar Nothing Nothing 15 | 16 | createSpinner :: I.ProgressIndicator 17 | createSpinner = 18 | I.Spinner snapshots (length snapshots) 75000 colors Nothing 19 | where 20 | snapshots = ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"] 21 | colors = cycle [Black, Red, Yellow, Green, Blue, Cyan, Magenta] 22 | 23 | progressWithIndicator :: Monoid b => (a -> IO b) -> I.ProgressIndicator -> [a] -> IO b 24 | progressWithIndicator f i terms = do 25 | I.printPrefix i 26 | (tid, indicator) <- I.start i $ length terms 27 | installChildInterruptHandler tid 28 | mconcat <$> PIO.parallel (ioOps indicator) <* I.stop indicator 29 | where 30 | ioOps i' = map (\t -> f t <* I.increment i') terms 31 | -------------------------------------------------------------------------------- /src/Unused/TermSearch.hs: -------------------------------------------------------------------------------- 1 | module Unused.TermSearch 2 | ( SearchResults(..) 3 | , SearchBackend(..) 4 | , SearchTerm 5 | , search 6 | ) where 7 | 8 | import qualified Data.Maybe as M 9 | import GHC.IO.Exception (ExitCode(ExitSuccess)) 10 | import qualified System.Process as P 11 | import Unused.TermSearch.Internal 12 | (commandLineOptions, parseSearchResult) 13 | import Unused.TermSearch.Types 14 | (SearchBackend(..), SearchResults(..)) 15 | import Unused.Types (SearchTerm, searchTermToString) 16 | 17 | search :: SearchBackend -> SearchTerm -> IO SearchResults 18 | search backend t = 19 | SearchResults . M.mapMaybe (parseSearchResult backend t) <$> 20 | (lines <$> performSearch backend (searchTermToString t)) 21 | 22 | performSearch :: SearchBackend -> String -> IO String 23 | performSearch b t = extractSearchResults b <$> searchOutcome 24 | where 25 | searchOutcome = P.readProcessWithExitCode (backendToCommand b) (commandLineOptions b t) "" 26 | backendToCommand Rg = "rg" 27 | backendToCommand Ag = "ag" 28 | 29 | extractSearchResults :: SearchBackend -> (ExitCode, String, String) -> String 30 | extractSearchResults Rg (ExitSuccess, stdout, _) = stdout 31 | extractSearchResults Rg (_, _, stderr) = stderr 32 | extractSearchResults Ag (_, stdout, _) = stdout 33 | -------------------------------------------------------------------------------- /stack.yaml: -------------------------------------------------------------------------------- 1 | # This file was automatically generated by stack init 2 | # For more information, see: http://docs.haskellstack.org/en/stable/yaml_configuration/ 3 | 4 | # Specifies the GHC version and set of packages available (e.g., lts-3.5, nightly-2015-09-21, ghc-7.10.2) 5 | resolver: lts-14.12 6 | 7 | # Local packages, usually specified by relative directory name 8 | packages: 9 | - '.' 10 | # Packages to be pulled from upstream that are not in the resolver (e.g., acme-missiles-0.3) 11 | extra-deps: 12 | - megaparsec-7.0.5 13 | - terminal-progress-bar-0.1.1.1 14 | 15 | # Override default flag values for local packages and extra-deps 16 | flags: {} 17 | 18 | # Extra package databases containing global packages 19 | extra-package-dbs: [] 20 | 21 | # Control whether we use the GHC we find on the path 22 | # system-ghc: true 23 | 24 | # Require a specific version of stack, using version ranges 25 | # require-stack-version: -any # Default 26 | # require-stack-version: >= 1.0.0 27 | 28 | # Override the architecture used by stack, especially useful on Windows 29 | # arch: i386 30 | # arch: x86_64 31 | 32 | # Extra directories used by stack for building 33 | # extra-include-dirs: [/path/to/dir] 34 | # extra-lib-dirs: [/path/to/dir] 35 | 36 | # Allow a newer minor version of GHC than the snapshot specifies 37 | # compiler-check: newer-minor 38 | 39 | pvp-bounds: both 40 | -------------------------------------------------------------------------------- /src/Unused/Util.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE TypeSynonymInstances #-} 2 | {-# LANGUAGE FlexibleInstances #-} 3 | 4 | module Unused.Util 5 | ( groupBy 6 | , stringToInt 7 | , safeHead 8 | , safeReadFile 9 | ) where 10 | 11 | import Control.Arrow ((&&&)) 12 | import qualified Control.Exception as E 13 | import qualified Data.ByteString.Char8 as C8 14 | import qualified Data.ByteString.Lazy.Char8 as Cl8 15 | import qualified Data.Char as C 16 | import Data.Function (on) 17 | import qualified Data.List as L 18 | 19 | groupBy :: (Ord b) => (a -> b) -> [a] -> [(b, [a])] 20 | groupBy f = map (f . head &&& id) . L.groupBy ((==) `on` f) . L.sortBy (compare `on` f) 21 | 22 | safeHead :: [a] -> Maybe a 23 | safeHead (x:_) = Just x 24 | safeHead _ = Nothing 25 | 26 | stringToInt :: String -> Maybe Int 27 | stringToInt xs 28 | | all C.isDigit xs = Just $ loop 0 xs 29 | | otherwise = Nothing 30 | where 31 | loop = foldl (\acc x -> acc * 10 + C.digitToInt x) 32 | 33 | class Readable a where 34 | readFile' :: FilePath -> IO a 35 | 36 | instance Readable String where 37 | readFile' = readFile 38 | 39 | instance Readable C8.ByteString where 40 | readFile' = C8.readFile 41 | 42 | instance Readable Cl8.ByteString where 43 | readFile' = Cl8.readFile 44 | 45 | safeReadFile :: Readable s => FilePath -> IO (Either E.IOException s) 46 | safeReadFile = E.try . readFile' 47 | -------------------------------------------------------------------------------- /test/Unused/UtilSpec.hs: -------------------------------------------------------------------------------- 1 | module Unused.UtilSpec 2 | ( main 3 | , spec 4 | ) where 5 | 6 | import Test.Hspec 7 | import Unused.Util 8 | 9 | data Person = Person 10 | { pName :: String 11 | , pAge :: Int 12 | } deriving (Eq, Show) 13 | 14 | main :: IO () 15 | main = hspec spec 16 | 17 | spec :: Spec 18 | spec = 19 | parallel $ do 20 | describe "groupBy" $ do 21 | it "groups by the result of a function" $ do 22 | let numbers = [1 .. 10] :: [Int] 23 | groupBy ((0 ==) . flip mod 2) numbers `shouldBe` 24 | [(False, [1, 3, 5, 7, 9]), (True, [2, 4, 6, 8, 10])] 25 | it "handles records" $ do 26 | let people = [Person "Jane" 10, Person "Jane" 20, Person "John" 20] 27 | groupBy pName people `shouldBe` 28 | [("Jane", [Person "Jane" 10, Person "Jane" 20]), ("John", [Person "John" 20])] 29 | groupBy pAge people `shouldBe` 30 | [(10, [Person "Jane" 10]), (20, [Person "Jane" 20, Person "John" 20])] 31 | describe "stringToInt" $ 32 | it "converts a String value to Maybe Int" $ do 33 | stringToInt "12345678" `shouldBe` Just 12345678 34 | stringToInt "0" `shouldBe` Just 0 35 | stringToInt "10591" `shouldBe` Just 10591 36 | stringToInt "bad" `shouldBe` Nothing 37 | -------------------------------------------------------------------------------- /app/Types.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE GeneralizedNewtypeDeriving #-} 2 | {-# LANGUAGE ConstraintKinds #-} 3 | 4 | module Types 5 | ( Options(..) 6 | , AppConfig 7 | , AppError(..) 8 | , App(..) 9 | ) where 10 | 11 | import Control.Monad.Except (ExceptT, MonadError) 12 | import Control.Monad.Reader (MonadIO, MonadReader, ReaderT) 13 | import Unused.CLI (SearchRunner) 14 | import Unused.Cache (FingerprintOutcome) 15 | import Unused.Grouping (CurrentGrouping) 16 | import Unused.ResultsClassifier (ParseConfigError) 17 | import Unused.TagsSource (TagSearchOutcome) 18 | import Unused.TermSearch (SearchBackend) 19 | import Unused.Types (RemovalLikelihood) 20 | 21 | data Options = Options 22 | { oSearchRunner :: SearchRunner 23 | , oSingleOccurrenceMatches :: Bool 24 | , oLikelihoods :: [RemovalLikelihood] 25 | , oAllLikelihoods :: Bool 26 | , oIgnoredPaths :: [String] 27 | , oGrouping :: CurrentGrouping 28 | , oWithoutCache :: Bool 29 | , oFromStdIn :: Bool 30 | , oCommitCount :: Maybe Int 31 | , oSearchBackend :: SearchBackend 32 | } 33 | 34 | type AppConfig = MonadReader Options 35 | 36 | data AppError 37 | = TagError TagSearchOutcome 38 | | InvalidConfigError [ParseConfigError] 39 | | CacheError FingerprintOutcome 40 | 41 | newtype App a = App 42 | { runApp :: ReaderT Options (ExceptT AppError IO) a 43 | } deriving (Monad, Functor, Applicative, AppConfig, MonadError AppError, MonadIO) 44 | -------------------------------------------------------------------------------- /src/Unused/TagsSource.hs: -------------------------------------------------------------------------------- 1 | module Unused.TagsSource 2 | ( TagSearchOutcome(..) 3 | , loadTagsFromFile 4 | , loadTagsFromPipe 5 | ) where 6 | 7 | import qualified Control.Exception as E 8 | import qualified Data.Bifunctor as BF 9 | import qualified Data.List as L 10 | import qualified Data.Text as T 11 | import qualified System.Directory as D 12 | import Unused.Util (safeReadFile) 13 | 14 | data TagSearchOutcome 15 | = TagsFileNotFound [String] 16 | | IOError E.IOException 17 | 18 | loadTagsFromPipe :: IO (Either TagSearchOutcome [String]) 19 | loadTagsFromPipe = fmap (Right . tokensFromTags) getContents 20 | 21 | loadTagsFromFile :: IO (Either TagSearchOutcome [String]) 22 | loadTagsFromFile = fmap (fmap tokensFromTags) tagsContent 23 | 24 | tokensFromTags :: String -> [String] 25 | tokensFromTags = filter validTokens . L.nub . tokenLocations 26 | where 27 | tokenLocations = map (token . T.splitOn "\t" . T.pack) . lines 28 | token = T.unpack . head 29 | 30 | validTokens :: String -> Bool 31 | validTokens = not . L.isPrefixOf "!_TAG" 32 | 33 | tagsContent :: IO (Either TagSearchOutcome String) 34 | tagsContent = D.findFile possibleTagsFileDirectories "tags" >>= eitherReadFile 35 | 36 | eitherReadFile :: Maybe String -> IO (Either TagSearchOutcome String) 37 | eitherReadFile Nothing = return $ Left $ TagsFileNotFound possibleTagsFileDirectories 38 | eitherReadFile (Just path) = BF.first IOError <$> safeReadFile path 39 | 40 | possibleTagsFileDirectories :: [String] 41 | possibleTagsFileDirectories = [".git", "tmp", "."] 42 | -------------------------------------------------------------------------------- /src/Unused/Cache/FindArgsFromIgnoredPaths.hs: -------------------------------------------------------------------------------- 1 | module Unused.Cache.FindArgsFromIgnoredPaths 2 | ( findArgs 3 | ) where 4 | 5 | import qualified Data.Char as C 6 | import qualified Data.List as L 7 | import qualified System.FilePath as FP 8 | 9 | findArgs :: [String] -> [String] 10 | findArgs = concatMap ignoreToFindArgs . validIgnoreOptions 11 | 12 | wildcardPrefix :: String -> String 13 | wildcardPrefix a@('*':'/':_) = a 14 | wildcardPrefix ('*':s) = "*/" ++ s 15 | wildcardPrefix ('/':s) = "*/" ++ s 16 | wildcardPrefix a = "*/" ++ a 17 | 18 | toExclusions :: String -> [String] 19 | toExclusions s = 20 | case (isWildcardFilename s, isMissingFilename s) of 21 | (True, _) -> ["-not", "-path", s] 22 | (_, True) -> ["-not", "-path", wildcardSuffix s] 23 | (_, False) -> ["-not", "-name", s, "-not", "-path", wildcardSuffix s] 24 | 25 | ignoreToFindArgs :: String -> [String] 26 | ignoreToFindArgs = toExclusions . wildcardPrefix 27 | 28 | wildcardSuffix :: String -> String 29 | wildcardSuffix s 30 | | isWildcardFilename s = s 31 | | "/" `L.isSuffixOf` s = s ++ "*" 32 | | otherwise = s ++ "/*" 33 | 34 | isWildcardFilename :: String -> Bool 35 | isWildcardFilename = elem '*' . FP.takeFileName 36 | 37 | isMissingFilename :: String -> Bool 38 | isMissingFilename = null . FP.takeFileName 39 | 40 | validIgnoreOptions :: [String] -> [String] 41 | validIgnoreOptions = filter isPath 42 | where 43 | isPath "" = False 44 | isPath ('/':_) = True 45 | isPath ('.':_) = True 46 | isPath s = C.isAlphaNum $ head s 47 | -------------------------------------------------------------------------------- /src/Unused/Aliases.hs: -------------------------------------------------------------------------------- 1 | module Unused.Aliases 2 | ( groupedTermsAndAliases 3 | , termsAndAliases 4 | ) where 5 | 6 | import qualified Data.List as L 7 | import Data.Text (Text) 8 | import qualified Data.Text as T 9 | import Unused.ResultsClassifier.Types 10 | import Unused.Types (SearchTerm(..), TermMatch, tmTerm) 11 | import Unused.Util (groupBy) 12 | 13 | groupedTermsAndAliases :: [TermMatch] -> [[TermMatch]] 14 | groupedTermsAndAliases = map snd . groupBy tmTerm 15 | 16 | termsAndAliases :: [TermAlias] -> [String] -> [SearchTerm] 17 | termsAndAliases [] = map OriginalTerm 18 | termsAndAliases as = L.nub . concatMap ((as >>=) . generateSearchTerms . T.pack) 19 | 20 | generateSearchTerms :: Text -> TermAlias -> [SearchTerm] 21 | generateSearchTerms term TermAlias {taFrom = from, taTransform = transform} = 22 | toTermWithAlias $ parsePatternForMatch (T.pack from) term 23 | where 24 | toTermWithAlias (Right (Just match)) = 25 | [OriginalTerm unpackedTerm, AliasTerm unpackedTerm (aliasedResult match)] 26 | toTermWithAlias _ = [OriginalTerm unpackedTerm] 27 | unpackedTerm = T.unpack term 28 | aliasedResult = T.unpack . transform 29 | 30 | parsePatternForMatch :: Text -> Text -> Either Text (Maybe Text) 31 | parsePatternForMatch aliasPattern term = findMatch $ T.splitOn wildcard aliasPattern 32 | where 33 | findMatch [prefix, suffix] = Right $ T.stripSuffix suffix =<< T.stripPrefix prefix term 34 | findMatch _ = Left $ T.pack $ "There was a problem with the pattern: " ++ show aliasPattern 35 | 36 | wildcard :: Text 37 | wildcard = "*" 38 | -------------------------------------------------------------------------------- /src/Unused/GitContext.hs: -------------------------------------------------------------------------------- 1 | module Unused.GitContext 2 | ( gitContextForResults 3 | ) where 4 | 5 | import qualified Data.List as L 6 | import qualified Data.Text as T 7 | import qualified System.Process as P 8 | import Unused.Types (TermResults(trGitContext), GitContext(..), GitCommit(..), RemovalLikelihood(High), removalLikelihood, resultAliases) 9 | 10 | newtype GitOutput = GitOutput { unOutput :: String } 11 | 12 | gitContextForResults :: Int -> (String, TermResults) -> IO [(String, TermResults)] 13 | gitContextForResults commitCount a@(token, results) = 14 | case removalLikelihood results of 15 | High -> do 16 | gitContext <- logToGitContext <$> gitLogSearchFor commitCount (resultAliases results) 17 | return [(token, results { trGitContext = Just gitContext })] 18 | _ -> return [a] 19 | 20 | -- 58e219e Allow developer-authored configurations 21 | -- 307dd20 Introduce internal yaml configuration of auto low likelihood match handling 22 | -- 3b627ee Allow multiple matches with single-occurring appropriate tokens 23 | -- f7a2e1a Add Hspec and tests around parsing 24 | logToGitContext :: GitOutput -> GitContext 25 | logToGitContext = 26 | GitContext . map GitCommit . shaList . unOutput 27 | where 28 | shaList = map (T.unpack . head . T.splitOn " " . T.pack) . lines 29 | 30 | gitLogSearchFor :: Int -> [String] -> IO GitOutput 31 | gitLogSearchFor commitCount ts = do 32 | (_, results, _) <- P.readProcessWithExitCode "git" ["log", "-G", L.intercalate "|" ts, "--oneline", "-n", show commitCount] "" 33 | return $ GitOutput results 34 | -------------------------------------------------------------------------------- /src/Unused/CLI/Views/MissingTagsFileError.hs: -------------------------------------------------------------------------------- 1 | module Unused.CLI.Views.MissingTagsFileError 2 | ( missingTagsFileError 3 | ) where 4 | 5 | import Unused.CLI.Util 6 | import qualified Unused.CLI.Views.Error as V 7 | import Unused.TagsSource (TagSearchOutcome(..)) 8 | 9 | missingTagsFileError :: TagSearchOutcome -> IO () 10 | missingTagsFileError e = do 11 | V.errorHeader "There was a problem finding a tags file." 12 | printOutcomeMessage e 13 | 14 | putStr "\n" 15 | 16 | setSGR [SetConsoleIntensity BoldIntensity] 17 | putStr "If you're generating a ctags file to a custom location, " 18 | putStrLn "you can pipe it into unused:" 19 | setSGR [Reset] 20 | 21 | putStrLn " cat custom/ctags | unused --stdin" 22 | 23 | putStr "\n" 24 | 25 | setSGR [SetConsoleIntensity BoldIntensity] 26 | putStrLn "You can find out more about Exuberant Ctags here:" 27 | setSGR [Reset] 28 | putStrLn " http://ctags.sourceforge.net/" 29 | 30 | putStr "\n" 31 | 32 | setSGR [SetConsoleIntensity BoldIntensity] 33 | putStrLn "You can read about a good git-based Ctags workflow here:" 34 | setSGR [Reset] 35 | putStrLn " http://tbaggery.com/2011/08/08/effortless-ctags-with-git.html" 36 | 37 | putStr "\n" 38 | 39 | printOutcomeMessage :: TagSearchOutcome -> IO () 40 | printOutcomeMessage (TagsFileNotFound directoriesSearched) = do 41 | putStrLn "Looked for a 'tags' file in the following directories:\n" 42 | mapM_ (\d -> putStrLn $ "* " ++ d) directoriesSearched 43 | printOutcomeMessage (IOError e) = do 44 | putStrLn "Received error when loading tags file:\n" 45 | putStrLn $ " " ++ show e 46 | -------------------------------------------------------------------------------- /src/Unused/Projection.hs: -------------------------------------------------------------------------------- 1 | module Unused.Projection where 2 | 3 | import qualified Data.Bifunctor as BF 4 | import Data.Monoid ((<>)) 5 | import Data.Text (Text) 6 | import qualified Data.Text as T 7 | import Data.Void (Void) 8 | import Text.Megaparsec 9 | import Text.Megaparsec.Char 10 | import Unused.Projection.Transform 11 | 12 | data ParsedTransform = ParsedTransform 13 | { ptPre :: Text 14 | , ptTransforms :: [Transform] 15 | , ptPost :: Text 16 | } 17 | 18 | type Parser = Parsec Void Text 19 | 20 | translate :: Text -> Either String (Text -> Text) 21 | translate template = applyTransform <$> parseTransform template 22 | 23 | applyTransform :: ParsedTransform -> Text -> Text 24 | applyTransform pt t = ptPre pt <> runTransformations t (ptTransforms pt) <> ptPost pt 25 | 26 | parseTransform :: Text -> Either String ParsedTransform 27 | parseTransform = BF.first show . parse parsedTransformParser "" 28 | 29 | parsedTransformParser :: Parser ParsedTransform 30 | parsedTransformParser = 31 | ParsedTransform <$> preTransformsParser <*> transformsParser <*> postTransformsParser 32 | 33 | preTransformsParser :: Parser Text 34 | preTransformsParser = T.pack <$> manyTill anySingle (char '{') 35 | 36 | transformsParser :: Parser [Transform] 37 | transformsParser = transformParser `sepBy` char '|' <* char '}' 38 | 39 | postTransformsParser :: Parser Text 40 | postTransformsParser = T.pack <$> many anySingle 41 | 42 | transformParser :: Parser Transform 43 | transformParser = do 44 | result <- string "camelcase" <|> string "snakecase" 45 | return $ 46 | case result of 47 | "camelcase" -> Camelcase 48 | "snakecase" -> Snakecase 49 | _ -> Noop 50 | -------------------------------------------------------------------------------- /src/Unused/TermSearch/Internal.hs: -------------------------------------------------------------------------------- 1 | module Unused.TermSearch.Internal 2 | ( commandLineOptions 3 | , parseSearchResult 4 | ) where 5 | 6 | import qualified Data.Char as C 7 | import qualified Data.Maybe as M 8 | import qualified Data.Text as T 9 | import Unused.TermSearch.Types (SearchBackend(..)) 10 | import Unused.Types (SearchTerm(..), TermMatch(..)) 11 | import Unused.Util (stringToInt) 12 | 13 | commandLineOptions :: SearchBackend -> String -> [String] 14 | commandLineOptions backend t = 15 | if regexSafeTerm t 16 | then regexFlags backend t ++ baseFlags backend 17 | else nonRegexFlags backend t ++ baseFlags backend 18 | 19 | parseSearchResult :: SearchBackend -> SearchTerm -> String -> Maybe TermMatch 20 | parseSearchResult backend term = maybeTermMatch backend . map T.unpack . T.splitOn ":" . T.pack 21 | where 22 | maybeTermMatch Rg [path, count] = Just $ toTermMatch term path $ countInt count 23 | maybeTermMatch Rg _ = Nothing 24 | maybeTermMatch Ag [_, path, count] = Just $ toTermMatch term path $ countInt count 25 | maybeTermMatch Ag _ = Nothing 26 | countInt = M.fromMaybe 0 . stringToInt 27 | toTermMatch (OriginalTerm t) path = TermMatch t path Nothing 28 | toTermMatch (AliasTerm t a) path = TermMatch t path (Just a) 29 | 30 | regexSafeTerm :: String -> Bool 31 | regexSafeTerm = all (\c -> C.isAlphaNum c || c == '_' || c == '-') 32 | 33 | nonRegexFlags :: SearchBackend -> String -> [String] 34 | nonRegexFlags Rg t = [t, ".", "-F"] 35 | nonRegexFlags Ag t = [t, ".", "-Q"] 36 | 37 | baseFlags :: SearchBackend -> [String] 38 | baseFlags Rg = ["-c", "-j", "1"] 39 | baseFlags Ag = ["-c", "--ackmate", "--ignore-dir", "tmp/unused"] 40 | 41 | regexFlags :: SearchBackend -> String -> [String] 42 | regexFlags _ t = ["(\\W|^)" ++ t ++ "(\\W|$)", "."] 43 | -------------------------------------------------------------------------------- /src/Unused/ResultsClassifier/Config.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE TemplateHaskell #-} 2 | 3 | module Unused.ResultsClassifier.Config 4 | ( loadConfig 5 | , loadAllConfigurations 6 | ) where 7 | 8 | import qualified Data.Bifunctor as BF 9 | import qualified Data.ByteString as BS 10 | import qualified Data.Either as E 11 | import qualified Data.FileEmbed as FE 12 | import qualified Data.Yaml as Y 13 | import qualified System.Directory as D 14 | import System.FilePath (()) 15 | import Unused.ResultsClassifier.Types (LanguageConfiguration, ParseConfigError(..)) 16 | import Unused.Util (safeReadFile) 17 | 18 | loadConfig :: Either String [LanguageConfiguration] 19 | loadConfig = decodeEither defaultConfigFile 20 | 21 | defaultConfigFile :: BS.ByteString 22 | defaultConfigFile = $(FE.embedFile "data/config.yml") 23 | 24 | loadAllConfigurations :: IO (Either [ParseConfigError] [LanguageConfiguration]) 25 | loadAllConfigurations = do 26 | homeDir <- D.getHomeDirectory 27 | let defaultConfig = addSourceToLeft "default config" loadConfig 28 | localConfig <- loadConfigFromFile ".unused.yml" 29 | userConfig <- loadConfigFromFile $ homeDir ".unused.yml" 30 | let (lefts, rights) = E.partitionEithers [defaultConfig, localConfig, userConfig] 31 | return $ 32 | if not (null lefts) 33 | then Left lefts 34 | else Right $ concat rights 35 | 36 | loadConfigFromFile :: String -> IO (Either ParseConfigError [LanguageConfiguration]) 37 | loadConfigFromFile path = 38 | either (const $ Right []) (addSourceToLeft path . decodeEither) <$> safeReadFile path 39 | 40 | addSourceToLeft :: String -> Either String c -> Either ParseConfigError c 41 | addSourceToLeft = BF.first . ParseConfigError 42 | 43 | decodeEither :: Y.FromJSON a => BS.ByteString -> Either String a 44 | decodeEither = BF.first Y.prettyPrintParseException . Y.decodeEither' 45 | -------------------------------------------------------------------------------- /src/Unused/CLI/ProgressIndicator/Internal.hs: -------------------------------------------------------------------------------- 1 | module Unused.CLI.ProgressIndicator.Internal 2 | ( start 3 | , stop 4 | , increment 5 | , printPrefix 6 | ) where 7 | 8 | import qualified Control.Concurrent as CC 9 | import qualified Control.Monad as M 10 | import qualified System.ProgressBar as PB 11 | import Unused.CLI.ProgressIndicator.Types (ProgressIndicator(..)) 12 | import Unused.CLI.Util 13 | 14 | start :: ProgressIndicator -> Int -> IO (CC.ThreadId, ProgressIndicator) 15 | start s@Spinner {} _ = do 16 | tid <- CC.forkIO $ runSpinner 0 s 17 | return (tid, s {sThreadId = Just tid}) 18 | start ProgressBar {} i = do 19 | (ref, tid) <- buildProgressBar $ toInteger i 20 | return (tid, ProgressBar (Just ref) (Just tid)) 21 | 22 | stop :: ProgressIndicator -> IO () 23 | stop ProgressBar {pbThreadId = Just tid} = CC.killThread tid 24 | stop Spinner {sThreadId = Just tid} = CC.killThread tid 25 | stop _ = return () 26 | 27 | increment :: ProgressIndicator -> IO () 28 | increment ProgressBar {pbProgressRef = Just ref} = PB.incProgress ref 1 29 | increment _ = return () 30 | 31 | printPrefix :: ProgressIndicator -> IO () 32 | printPrefix ProgressBar {} = putStr "\n\n" 33 | printPrefix Spinner {} = putStr " " 34 | 35 | runSpinner :: Int -> ProgressIndicator -> IO () 36 | runSpinner i s@Spinner {sDelay = delay, sSnapshots = snapshots, sColors = colors, sLength = length'} = 37 | M.forever $ do 38 | setSGR [SetColor Foreground Dull currentColor] 39 | putStr currentSnapshot 40 | cursorBackward 1 41 | CC.threadDelay delay 42 | runSpinner (i + 1) s 43 | where 44 | currentSnapshot = snapshots !! (i `mod` snapshotLength) 45 | currentColor = colors !! (i `div` snapshotLength) 46 | snapshotLength = length' 47 | runSpinner _ _ = return () 48 | 49 | buildProgressBar :: Integer -> IO (PB.ProgressRef, CC.ThreadId) 50 | buildProgressBar = PB.startProgress (PB.msg message) PB.percentage progressBarWidth 51 | where 52 | message = "Working" 53 | progressBarWidth = 60 54 | -------------------------------------------------------------------------------- /src/Unused/LikelihoodCalculator.hs: -------------------------------------------------------------------------------- 1 | module Unused.LikelihoodCalculator 2 | ( calculateLikelihood 3 | , LanguageConfiguration 4 | ) where 5 | 6 | import qualified Data.List as L 7 | import qualified Data.Maybe as M 8 | import qualified Unused.ResponseFilter as RF 9 | import Unused.ResultsClassifier (LanguageConfiguration(..), LowLikelihoodMatch(..)) 10 | import Unused.Types (TermResults(..), Occurrences(..), RemovalLikelihood(..), Removal(..), totalOccurrenceCount) 11 | 12 | calculateLikelihood :: [LanguageConfiguration] -> TermResults -> TermResults 13 | calculateLikelihood lcs r = 14 | r { trRemoval = uncurry Removal newLikelihood } 15 | where 16 | newLikelihood 17 | | M.isJust firstAutoLowLikelihood = (Low, autoLowLikelihoodMessage) 18 | | singleNonTestUsage r && testsExist r = (High, "only the definition and corresponding tests exist") 19 | | doubleNonTestUsage r && testsExist r = (Medium, "only the definition and one other use, along with tests, exists") 20 | | totalScore < 2 = (High, "occurs once") 21 | | totalScore < 6 = (Medium, "used semi-frequently") 22 | | totalScore >= 6 = (Low, "used frequently") 23 | | otherwise = (Unknown, "could not determine likelihood") 24 | totalScore = totalOccurrenceCount r 25 | firstAutoLowLikelihood = L.find (`RF.autoLowLikelihood` r) lcs 26 | autoLowLikelihoodMessage = maybe "" languageConfirmationMessage firstAutoLowLikelihood 27 | 28 | languageConfirmationMessage :: LanguageConfiguration -> String 29 | languageConfirmationMessage lc = 30 | langFramework ++ ": allowed term or " ++ lowLikelihoodNames 31 | where 32 | langFramework = lcName lc 33 | lowLikelihoodNames = L.intercalate ", " $ map smName $ lcAutoLowLikelihood lc 34 | 35 | singleNonTestUsage :: TermResults -> Bool 36 | singleNonTestUsage = (1 ==) . oOccurrences . trAppOccurrences 37 | 38 | doubleNonTestUsage :: TermResults -> Bool 39 | doubleNonTestUsage = (2 ==) . oOccurrences . trAppOccurrences 40 | 41 | testsExist :: TermResults -> Bool 42 | testsExist = (> 0) . oOccurrences . trTestOccurrences 43 | -------------------------------------------------------------------------------- /src/Unused/Cache/DirectoryFingerprint.hs: -------------------------------------------------------------------------------- 1 | module Unused.Cache.DirectoryFingerprint 2 | ( FingerprintOutcome(..) 3 | , sha 4 | ) where 5 | 6 | import Control.Monad.Reader (ReaderT, asks, liftIO, runReaderT) 7 | import qualified Data.Char as C 8 | import qualified Data.Maybe as M 9 | import qualified System.Directory as D 10 | import qualified System.Process as P 11 | import Unused.Cache.FindArgsFromIgnoredPaths (findArgs) 12 | import Unused.Util (safeHead, safeReadFile) 13 | 14 | newtype MD5ExecutablePath = MD5ExecutablePath 15 | { toMD5String :: String 16 | } 17 | 18 | type MD5Config = ReaderT MD5ExecutablePath IO 19 | 20 | newtype FingerprintOutcome = 21 | MD5ExecutableNotFound [String] 22 | 23 | sha :: IO (Either FingerprintOutcome String) 24 | sha = do 25 | md5Executable' <- md5Executable 26 | case md5Executable' of 27 | Just exec -> 28 | Right . getSha <$> 29 | runReaderT (fileList >>= sortInput >>= md5Result) (MD5ExecutablePath exec) 30 | Nothing -> return $ Left $ MD5ExecutableNotFound supportedMD5Executables 31 | where 32 | getSha = takeWhile C.isAlphaNum . M.fromMaybe "" . safeHead . lines 33 | 34 | fileList :: MD5Config String 35 | fileList = do 36 | filterNamePathArgs <- liftIO $ findArgs <$> ignoredPaths 37 | md5exec <- asks toMD5String 38 | let args = 39 | [".", "-type", "f", "-not", "-path", "*/.git/*"] ++ 40 | filterNamePathArgs ++ ["-exec", md5exec, "{}", "+"] 41 | liftIO $ P.readProcess "find" args "" 42 | 43 | sortInput :: String -> MD5Config String 44 | sortInput = liftIO . P.readProcess "sort" ["-k", "2"] 45 | 46 | md5Result :: String -> MD5Config String 47 | md5Result r = do 48 | md5exec <- asks toMD5String 49 | liftIO $ P.readProcess md5exec [] r 50 | 51 | ignoredPaths :: IO [String] 52 | ignoredPaths = either (const []) id <$> (fmap lines <$> safeReadFile ".gitignore") 53 | 54 | md5Executable :: IO (Maybe String) 55 | md5Executable = safeHead . concat <$> mapM D.findExecutables supportedMD5Executables 56 | 57 | supportedMD5Executables :: [String] 58 | supportedMD5Executables = ["md5", "md5sum"] 59 | -------------------------------------------------------------------------------- /src/Unused/CLI/Util.hs: -------------------------------------------------------------------------------- 1 | module Unused.CLI.Util 2 | ( resetScreen 3 | , withRuntime 4 | , installChildInterruptHandler 5 | , module System.Console.ANSI 6 | ) where 7 | 8 | import qualified Control.Concurrent as CC 9 | import qualified Control.Concurrent.ParallelIO as PIO 10 | import qualified Control.Exception as E 11 | import qualified Control.Monad as M 12 | import System.Console.ANSI 13 | import qualified System.Exit as Ex 14 | import System.IO (hSetBuffering, BufferMode(NoBuffering), stdout) 15 | import qualified System.Posix.Signals as S 16 | 17 | withRuntime :: IO a -> IO a 18 | withRuntime a = do 19 | hSetBuffering stdout NoBuffering 20 | withInterruptHandler $ withoutCursor a <* PIO.stopGlobalPool 21 | 22 | resetScreen :: IO () 23 | resetScreen = do 24 | clearScreen 25 | setCursorPosition 0 0 26 | 27 | withoutCursor :: IO a -> IO a 28 | withoutCursor body = do 29 | hideCursor 30 | body <* showCursor 31 | 32 | withInterruptHandler :: IO a -> IO a 33 | withInterruptHandler body = do 34 | tid <- CC.myThreadId 35 | M.void $ S.installHandler S.keyboardSignal (S.Catch (handleInterrupt tid)) Nothing 36 | body 37 | 38 | installChildInterruptHandler :: CC.ThreadId -> IO () 39 | installChildInterruptHandler tid = do 40 | currentThread <- CC.myThreadId 41 | M.void $ S.installHandler S.keyboardSignal (S.Catch (handleChildInterrupt currentThread tid)) Nothing 42 | 43 | handleInterrupt :: CC.ThreadId -> IO () 44 | handleInterrupt tid = do 45 | resetScreenState 46 | E.throwTo tid $ Ex.ExitFailure interruptExitCode 47 | 48 | handleChildInterrupt :: CC.ThreadId -> CC.ThreadId -> IO () 49 | handleChildInterrupt parentTid childTid = do 50 | CC.killThread childTid 51 | resetScreenState 52 | E.throwTo parentTid $ Ex.ExitFailure interruptExitCode 53 | handleInterrupt parentTid 54 | 55 | interruptExitCode :: Int 56 | interruptExitCode = 57 | signalToInt $ 128 + S.keyboardSignal 58 | where 59 | signalToInt s = read $ show s :: Int 60 | 61 | resetScreenState :: IO () 62 | resetScreenState = do 63 | resetScreen 64 | showCursor 65 | setSGR [Reset] 66 | -------------------------------------------------------------------------------- /src/Unused/CLI/Views/SearchResult.hs: -------------------------------------------------------------------------------- 1 | module Unused.CLI.Views.SearchResult 2 | ( ResultsFormat(..) 3 | , searchResults 4 | ) where 5 | 6 | import Control.Arrow ((&&&)) 7 | import qualified Data.Map.Strict as Map 8 | import Unused.CLI.Util 9 | import qualified Unused.CLI.Views.NoResultsFound as V 10 | import Unused.CLI.Views.SearchResult.ColumnFormatter 11 | import qualified Unused.CLI.Views.SearchResult.ListResult as V 12 | import qualified Unused.CLI.Views.SearchResult.TableResult as V 13 | import Unused.CLI.Views.SearchResult.Types 14 | import Unused.Grouping (GroupedTerms, Grouping(..)) 15 | import Unused.Types (TermMatch, TermMatchSet, TermResults(..)) 16 | 17 | searchResults :: ResultsFormat -> [GroupedTerms] -> IO () 18 | searchResults format terms = do 19 | resetScreen 20 | runReaderT (printFormattedTerms terms) resultsOptions 21 | where 22 | columnFormatter = buildColumnFormatter $ termsToResults terms 23 | resultsOptions = ResultsOptions columnFormatter format 24 | termsToResults = concatMap (Map.elems . snd) 25 | 26 | printFormattedTerms :: [GroupedTerms] -> ResultsPrinter () 27 | printFormattedTerms [] = liftIO V.noResultsFound 28 | printFormattedTerms ts = mapM_ printGroupingSection ts 29 | 30 | listFromMatchSet :: TermMatchSet -> [(String, TermResults)] 31 | listFromMatchSet = Map.toList 32 | 33 | printGroupingSection :: GroupedTerms -> ResultsPrinter () 34 | printGroupingSection (g, tms) = do 35 | liftIO $ printGrouping g 36 | mapM_ printTermResults $ listFromMatchSet tms 37 | 38 | printGrouping :: Grouping -> IO () 39 | printGrouping NoGrouping = return () 40 | printGrouping g = do 41 | putStr "\n" 42 | setSGR [SetColor Foreground Vivid Black] 43 | setSGR [SetConsoleIntensity BoldIntensity] 44 | print g 45 | setSGR [Reset] 46 | 47 | printTermResults :: (String, TermResults) -> ResultsPrinter () 48 | printTermResults = uncurry printMatches . (id &&& trMatches) . snd 49 | 50 | printMatches :: TermResults -> [TermMatch] -> ResultsPrinter () 51 | printMatches r ms = do 52 | outputFormat' <- outputFormat 53 | case outputFormat' of 54 | Column -> V.printTable r ms 55 | List -> V.printList r ms 56 | -------------------------------------------------------------------------------- /data/config.yml: -------------------------------------------------------------------------------- 1 | - name: Rails 2 | aliases: 3 | - from: "*?" 4 | to: "be_{}" 5 | - from: "has_*?" 6 | to: "have_{}" 7 | - from: "*Validator" 8 | to: "{snakecase}" 9 | allowedTerms: 10 | # serialization 11 | - as_json 12 | # inflection 13 | - Inflector 14 | # Concerns 15 | - ClassMethods 16 | - class_methods 17 | - included 18 | # rendering 19 | - to_partial_path 20 | autoLowLikelihood: 21 | - name: Migration 22 | pathStartsWith: db/migrate/ 23 | classOrModule: true 24 | appOccurrences: 1 25 | - name: Migration Helper 26 | pathStartsWith: db/migrate/ 27 | allowedTerms: 28 | - up 29 | - down 30 | - change 31 | - index 32 | - name: i18n 33 | allowedTerms: 34 | - t 35 | - l 36 | pathEndsWith: .rb 37 | - name: Controller 38 | pathStartsWith: app/controllers 39 | termEndsWith: Controller 40 | classOrModule: true 41 | - name: Helper 42 | pathStartsWith: app/helpers 43 | termEndsWith: Helper 44 | classOrModule: true 45 | - name: Phoenix 46 | allowedTerms: 47 | - Mixfile 48 | - __using__ 49 | autoLowLikelihood: 50 | - name: Migration 51 | pathStartsWith: priv/repo/migrations 52 | classOrModule: true 53 | - name: View 54 | pathStartsWith: web/views/ 55 | termEndsWith: View 56 | classOrModule: true 57 | - name: Test 58 | pathStartsWith: test/ 59 | termEndsWith: Test 60 | classOrModule: true 61 | - name: Controller actions 62 | pathStartsWith: web/controllers 63 | allowedTerms: 64 | - index 65 | - new 66 | - create 67 | - show 68 | - edit 69 | - update 70 | - destroy 71 | - name: Haskell 72 | allowedTerms: [] 73 | autoLowLikelihood: 74 | - name: Spec 75 | pathStartsWith: test/ 76 | termEndsWith: Spec 77 | classOrModule: true 78 | - name: Cabalfile 79 | pathEndsWith: .cabal 80 | appOccurrences: 1 81 | - name: TypeClasses 82 | termEquals: instance 83 | pathEndsWith: .hs 84 | - name: Spec functions 85 | termEquals: spec 86 | pathStartsWith: test/ 87 | -------------------------------------------------------------------------------- /src/Unused/Cache.hs: -------------------------------------------------------------------------------- 1 | module Unused.Cache 2 | ( FingerprintOutcome(..) 3 | , cached 4 | ) where 5 | 6 | import Control.Monad.Reader (ReaderT, runReaderT, ask, liftIO) 7 | import qualified Data.ByteString.Lazy as BS 8 | import Data.Csv (FromRecord, ToRecord, HasHeader(..), encode, decode) 9 | import qualified Data.Vector as V 10 | import qualified System.Directory as D 11 | import Unused.Cache.DirectoryFingerprint (FingerprintOutcome(..), sha) 12 | import Unused.Util (safeReadFile) 13 | 14 | newtype CacheFileName = CacheFileName String 15 | type Cache = ReaderT CacheFileName IO 16 | 17 | cached :: (FromRecord a, ToRecord a) => String -> IO [a] -> IO (Either FingerprintOutcome [a]) 18 | cached cachePrefix f = 19 | mapM fromCache =<< cacheFileName cachePrefix 20 | where 21 | fromCache = runReaderT $ maybe (writeCache =<< liftIO f) return =<< readCache 22 | 23 | writeCache :: ToRecord a => [a] -> Cache [a] 24 | writeCache [] = return [] 25 | writeCache contents = do 26 | ensureCacheDirectoryExists 27 | writeContentsToCacheFile contents =<< ask 28 | return contents 29 | 30 | readCache :: FromRecord a => Cache (Maybe [a]) 31 | readCache = 32 | either 33 | (const Nothing) 34 | (processCsv . decode NoHeader) 35 | <$> (readFromCache =<< ask) 36 | where 37 | processCsv = either (const Nothing) (Just . V.toList) 38 | readFromCache (CacheFileName fileName) = liftIO $ safeReadFile fileName 39 | 40 | cacheFileName :: String -> IO (Either FingerprintOutcome CacheFileName) 41 | cacheFileName context = do 42 | putStrLn "\n\nCalculating cache fingerprint... " 43 | fmap (CacheFileName . toFileName) <$> sha 44 | where 45 | toFileName s = cacheDirectory ++ "/" ++ context ++ "-" ++ s ++ ".csv" 46 | 47 | cacheDirectory :: String 48 | cacheDirectory = "tmp/unused" 49 | 50 | ensureCacheDirectoryExists :: Cache () 51 | ensureCacheDirectoryExists = 52 | liftIO $ D.createDirectoryIfMissing True cacheDirectory 53 | 54 | writeContentsToCacheFile :: ToRecord a => [a] -> CacheFileName -> Cache () 55 | writeContentsToCacheFile contents (CacheFileName fileName) = 56 | liftIO $ BS.writeFile fileName $ encode contents 57 | -------------------------------------------------------------------------------- /NEWS: -------------------------------------------------------------------------------- 1 | 0.10.0.0 (October 29, 2019) 2 | Update external dependency versions 3 | 4 | 0.9.0.0 (August 27, 2018) 5 | Update external dependency versions 6 | 7 | 0.8.0.0 (April 24, 2017) 8 | Allow use of `rg` instead of `ag` with the option `--search rg` 9 | 10 | 0.7.0.0 (January 12, 2017) 11 | Explicitly set dependencies for inflections, megaparsec, and cassava 12 | Remove occurrence count from output 13 | 14 | 0.6.1.1 (August 30, 2016) 15 | Update unused.cabal to ensure data/config.yml is included in distribution 16 | 17 | 0.6.1.0 (August 20, 2016) 18 | Conditionally import Data.Monoid.<> to address Homebrew compilation issues 19 | Replace reading config from FS with FileEmbed 20 | 21 | 0.6.0.1 (July 19, 2016) 22 | Convert ParseError to String prior to passing the value around 23 | 24 | 0.6.0.0 (July 19, 2016) 25 | [Alpha] Projection-style transformations for aliases 26 | Improve likelihood output for single-occurrence terms 27 | Increase safety of reading files 28 | Improve documentation including installation via Stack, common troubleshooting scenarios 29 | 30 | 0.5.0.2 (June 24, 2016) 31 | Remove final -Werror flag 32 | 33 | 0.5.0.1 (June 24, 2016) 34 | Update unused.cabal to remove -O flag entirely (for Hackage upload) 35 | 36 | 0.5.0.0 (June 24, 2016) 37 | Prepare for upload to Hackage 38 | Include flag to display most recent git commit SHAs per token to help track down removal commits 39 | Refactor internals to use monad transformer stack; other internal refactorings 40 | Improve matcher performance by removing regular expressions 41 | Improve documentation 42 | 43 | 0.4.0.0 (June 10, 2016) 44 | Allow for custom configurations on a per-user and per-project basis 45 | 46 | 0.3.0.0 (June 4, 2016) 47 | Introduce aliases for RSpec predicate matchers 48 | Remove certain instances of regex use for speed improvements 49 | Improve framework support for Rails and Phoenix 50 | Use .gitignore to reduce number of checksummed files for directory fingerprinting 51 | Fix bug where matches at the very beginning or end of a file weren't captured 52 | 53 | 0.2.0.0 (May 22, 2016) 54 | Load tags automatically 55 | Allow users to opt into reading tokens from stdin 56 | Improve language/framework support 57 | Increase speed drastically 58 | Allow users to opt into a caching mechanism 59 | Ensure Ctrl-C stops all work 60 | Better match tokens during search to improve accuracy 61 | Allow users to group results 62 | Parallelize search 63 | Improve usage likelihood messaging 64 | 65 | 0.1.1.0 (May 12, 2016) 66 | Improve Elixir support 67 | Improve documentation 68 | Fix bug where certain tokens were unable to be parsed 69 | 70 | 0.1.0.0 (May 11, 2016) 71 | First version 72 | -------------------------------------------------------------------------------- /src/Unused/CLI/Views/SearchResult/ListResult.hs: -------------------------------------------------------------------------------- 1 | module Unused.CLI.Views.SearchResult.ListResult 2 | ( printList 3 | ) where 4 | 5 | import qualified Control.Monad as M 6 | import Data.List ((\\)) 7 | import qualified Data.List as L 8 | import Unused.CLI.Util 9 | import qualified Unused.CLI.Views.SearchResult.Internal as SR 10 | import qualified Unused.CLI.Views.SearchResult.Types as SR 11 | import Unused.Types (TermResults(..), GitContext(..), GitCommit(..), TermMatch(..), tmDisplayTerm, totalFileCount, totalOccurrenceCount) 12 | 13 | printList :: TermResults -> [TermMatch] -> SR.ResultsPrinter () 14 | printList r ms = SR.liftIO $ 15 | M.forM_ ms $ \m -> do 16 | printTermAndOccurrences r m 17 | printAliases r 18 | printFilePath m 19 | printSHAs r 20 | printRemovalReason r 21 | putStr "\n" 22 | 23 | printTermAndOccurrences :: TermResults -> TermMatch -> IO () 24 | printTermAndOccurrences r m = do 25 | setSGR [SetColor Foreground Dull (SR.termColor r)] 26 | setSGR [SetConsoleIntensity BoldIntensity] 27 | putStr " " 28 | setSGR [SetUnderlining SingleUnderline] 29 | putStr $ tmDisplayTerm m 30 | setSGR [Reset] 31 | 32 | setSGR [SetColor Foreground Vivid Cyan] 33 | setSGR [SetConsoleIntensity NormalIntensity] 34 | putStr " (" 35 | putStr $ pluralize (totalFileCount r) "file" "files" 36 | putStr ", " 37 | putStr $ pluralize (totalOccurrenceCount r) "occurrence" "occurrences" 38 | putStr ")" 39 | setSGR [Reset] 40 | putStr "\n" 41 | 42 | printAliases :: TermResults -> IO () 43 | printAliases r = M.when anyAliases $ do 44 | printHeader " Aliases: " 45 | putStrLn $ L.intercalate ", " remainingAliases 46 | where 47 | anyAliases = not $ null remainingAliases 48 | remainingAliases = trTerms r \\ [trTerm r] 49 | 50 | printFilePath :: TermMatch -> IO () 51 | printFilePath m = do 52 | printHeader " File Path: " 53 | setSGR [SetColor Foreground Dull Cyan] 54 | putStrLn $ tmPath m 55 | setSGR [Reset] 56 | 57 | printSHAs :: TermResults -> IO () 58 | printSHAs r = 59 | case mshas of 60 | Nothing -> M.void $ putStr "" 61 | Just shas' -> do 62 | printHeader " Recent SHAs: " 63 | putStrLn $ L.intercalate ", " shas' 64 | where 65 | mshas = (map gcSha . gcCommits) <$> trGitContext r 66 | 67 | printRemovalReason :: TermResults -> IO () 68 | printRemovalReason r = do 69 | printHeader " Reason: " 70 | putStrLn $ SR.removalReason r 71 | 72 | printHeader :: String -> IO () 73 | printHeader v = do 74 | setSGR [SetConsoleIntensity BoldIntensity] 75 | putStr v 76 | setSGR [SetConsoleIntensity NormalIntensity] 77 | 78 | pluralize :: Int -> String -> String -> String 79 | pluralize i@1 singular _ = show i ++ " " ++ singular 80 | pluralize i _ plural = show i ++ " " ++ plural 81 | -------------------------------------------------------------------------------- /test/Unused/TermSearch/InternalSpec.hs: -------------------------------------------------------------------------------- 1 | module Unused.TermSearch.InternalSpec 2 | ( main 3 | , spec 4 | ) where 5 | 6 | import Test.Hspec 7 | import Unused.TermSearch.Internal 8 | import Unused.TermSearch.Types 9 | import Unused.Types 10 | 11 | main :: IO () 12 | main = hspec spec 13 | 14 | spec :: Spec 15 | spec = 16 | parallel $ do 17 | describe "commandLineOptions" $ do 18 | it "does not use regular expressions when the term contains non-word characters" $ do 19 | commandLineOptions Ag "can_do_things?" `shouldBe` 20 | ["can_do_things?", ".", "-Q", "-c", "--ackmate", "--ignore-dir", "tmp/unused"] 21 | commandLineOptions Ag "no_way!" `shouldBe` 22 | ["no_way!", ".", "-Q", "-c", "--ackmate", "--ignore-dir", "tmp/unused"] 23 | commandLineOptions Ag "[]=" `shouldBe` 24 | ["[]=", ".", "-Q", "-c", "--ackmate", "--ignore-dir", "tmp/unused"] 25 | commandLineOptions Ag "window.globalOverride" `shouldBe` 26 | [ "window.globalOverride" 27 | , "." 28 | , "-Q" 29 | , "-c" 30 | , "--ackmate" 31 | , "--ignore-dir" 32 | , "tmp/unused" 33 | ] 34 | commandLineOptions Rg "can_do_things?" `shouldBe` 35 | ["can_do_things?", ".", "-F", "-c", "-j", "1"] 36 | commandLineOptions Rg "no_way!" `shouldBe` ["no_way!", ".", "-F", "-c", "-j", "1"] 37 | commandLineOptions Rg "[]=" `shouldBe` ["[]=", ".", "-F", "-c", "-j", "1"] 38 | commandLineOptions Rg "window.globalOverride" `shouldBe` 39 | ["window.globalOverride", ".", "-F", "-c", "-j", "1"] 40 | it "uses regular expression match with surrounding non-word matches for accuracy" $ do 41 | commandLineOptions Ag "awesome_method" `shouldBe` 42 | [ "(\\W|^)awesome_method(\\W|$)" 43 | , "." 44 | , "-c" 45 | , "--ackmate" 46 | , "--ignore-dir" 47 | , "tmp/unused" 48 | ] 49 | commandLineOptions Rg "awesome_method" `shouldBe` 50 | ["(\\W|^)awesome_method(\\W|$)", ".", "-c", "-j", "1"] 51 | describe "parseSearchResult" $ do 52 | it "parses normal results from `ag` to a TermMatch" $ do 53 | parseSearchResult Ag (OriginalTerm "method_name") ":app/models/foo.rb:123" `shouldBe` 54 | (Just $ TermMatch "method_name" "app/models/foo.rb" Nothing 123) 55 | parseSearchResult Rg (OriginalTerm "method_name") "app/models/foo.rb:123" `shouldBe` 56 | (Just $ TermMatch "method_name" "app/models/foo.rb" Nothing 123) 57 | it "returns Nothing when it cannot parse" $ do 58 | parseSearchResult Ag (OriginalTerm "method_name") "" `shouldBe` Nothing 59 | parseSearchResult Rg (OriginalTerm "method_name") "" `shouldBe` Nothing 60 | -------------------------------------------------------------------------------- /src/Unused/ResponseFilter.hs: -------------------------------------------------------------------------------- 1 | module Unused.ResponseFilter 2 | ( withOneOccurrence 3 | , withLikelihoods 4 | , oneOccurence 5 | , ignoringPaths 6 | , isClassOrModule 7 | , autoLowLikelihood 8 | , updateMatches 9 | ) where 10 | 11 | import qualified Data.Char as C 12 | import qualified Data.List as L 13 | import qualified Data.Map.Strict as Map 14 | import Unused.ResultsClassifier 15 | (LanguageConfiguration(..), LowLikelihoodMatch(..), Matcher(..), 16 | Position(..)) 17 | import Unused.Types 18 | (Removal(..), RemovalLikelihood, TermMatch(..), TermMatchSet, 19 | TermResults(..), appOccurrenceCount, totalOccurrenceCount) 20 | 21 | withOneOccurrence :: TermMatchSet -> TermMatchSet 22 | withOneOccurrence = Map.filterWithKey (const oneOccurence) 23 | 24 | oneOccurence :: TermResults -> Bool 25 | oneOccurence = (== 1) . totalOccurrenceCount 26 | 27 | withLikelihoods :: [RemovalLikelihood] -> TermMatchSet -> TermMatchSet 28 | withLikelihoods [] = id 29 | withLikelihoods l = Map.filterWithKey (const $ includesLikelihood l) 30 | 31 | ignoringPaths :: [String] -> TermMatchSet -> TermMatchSet 32 | ignoringPaths xs = updateMatches newMatches 33 | where 34 | newMatches = filter (not . matchesPath . tmPath) 35 | matchesPath p = any (`L.isInfixOf` p) xs 36 | 37 | includesLikelihood :: [RemovalLikelihood] -> TermResults -> Bool 38 | includesLikelihood l = (`elem` l) . rLikelihood . trRemoval 39 | 40 | isClassOrModule :: TermResults -> Bool 41 | isClassOrModule = startsWithUpper . trTerm 42 | where 43 | startsWithUpper [] = False 44 | startsWithUpper (a:_) = C.isUpper a 45 | 46 | autoLowLikelihood :: LanguageConfiguration -> TermResults -> Bool 47 | autoLowLikelihood l r = isAllowedTerm r allowedTerms || or anySinglesOkay 48 | where 49 | allowedTerms = lcAllowedTerms l 50 | anySinglesOkay = map (\sm -> classOrModule sm r && matchesToBool (smMatchers sm)) singles 51 | singles = lcAutoLowLikelihood l 52 | classOrModule = classOrModuleFunction . smClassOrModule 53 | matchesToBool :: [Matcher] -> Bool 54 | matchesToBool [] = False 55 | matchesToBool a = all (`matcherToBool` r) a 56 | 57 | classOrModuleFunction :: Bool -> TermResults -> Bool 58 | classOrModuleFunction True = isClassOrModule 59 | classOrModuleFunction False = const True 60 | 61 | matcherToBool :: Matcher -> TermResults -> Bool 62 | matcherToBool (Path p v) = any (positionToTest p v) . paths 63 | matcherToBool (Term p v) = positionToTest p v . trTerm 64 | matcherToBool (AppOccurrences i) = (== i) . appOccurrenceCount 65 | matcherToBool (AllowedTerms ts) = (`isAllowedTerm` ts) 66 | 67 | positionToTest :: Position -> (String -> String -> Bool) 68 | positionToTest StartsWith = L.isPrefixOf 69 | positionToTest EndsWith = L.isSuffixOf 70 | positionToTest Equals = (==) 71 | 72 | paths :: TermResults -> [String] 73 | paths = fmap tmPath . trMatches 74 | 75 | updateMatches :: ([TermMatch] -> [TermMatch]) -> TermMatchSet -> TermMatchSet 76 | updateMatches fm = Map.map (updateMatchesWith $ fm . trMatches) 77 | where 78 | updateMatchesWith f tr = tr {trMatches = f tr} 79 | 80 | isAllowedTerm :: TermResults -> [String] -> Bool 81 | isAllowedTerm = elem . trTerm 82 | -------------------------------------------------------------------------------- /test/Unused/LikelihoodCalculatorSpec.hs: -------------------------------------------------------------------------------- 1 | module Unused.LikelihoodCalculatorSpec 2 | ( main 3 | , spec 4 | ) where 5 | 6 | import Test.Hspec 7 | import Unused.LikelihoodCalculator 8 | import Unused.ResultsClassifier 9 | import Unused.Types 10 | 11 | main :: IO () 12 | main = hspec spec 13 | 14 | spec :: Spec 15 | spec = 16 | parallel $ 17 | describe "calculateLikelihood" $ do 18 | it "prefers language-specific checks first" $ do 19 | let railsMatches = 20 | [ TermMatch 21 | "ApplicationController" 22 | "app/controllers/application_controller.rb" 23 | Nothing 24 | 1 25 | ] 26 | removalLikelihood' railsMatches `shouldBe` Low 27 | let elixirMatches = [TermMatch "AwesomeView" "web/views/awesome_view.ex" Nothing 1] 28 | removalLikelihood' elixirMatches `shouldBe` Low 29 | it "weighs widely-used methods as low likelihood" $ do 30 | let matches = 31 | [ TermMatch "full_name" "app/models/user.rb" Nothing 4 32 | , TermMatch "full_name" "app/views/application/_auth_header.rb" Nothing 1 33 | , TermMatch "full_name" "app/mailers/user_mailer.rb" Nothing 1 34 | , TermMatch "full_name" "spec/models/user_spec.rb" Nothing 10 35 | ] 36 | removalLikelihood' matches `shouldBe` Low 37 | it "weighs only-occurs-once methods as high likelihood" $ do 38 | let matches = [TermMatch "obscure_method" "app/models/user.rb" Nothing 1] 39 | removalLikelihood' matches `shouldBe` High 40 | it "weighs methods that seem to only be tested and never used as high likelihood" $ do 41 | let matches = 42 | [ TermMatch "obscure_method" "app/models/user.rb" Nothing 1 43 | , TermMatch "obscure_method" "spec/models/user_spec.rb" Nothing 5 44 | ] 45 | removalLikelihood' matches `shouldBe` High 46 | it 47 | "weighs methods that seem to only be tested and used in one other area as medium likelihood" $ do 48 | let matches = 49 | [ TermMatch "obscure_method" "app/models/user.rb" Nothing 1 50 | , TermMatch "obscure_method" "app/controllers/user_controller.rb" Nothing 1 51 | , TermMatch "obscure_method" "spec/models/user_spec.rb" Nothing 5 52 | , TermMatch 53 | "obscure_method" 54 | "spec/controllers/user_controller_spec.rb" 55 | Nothing 56 | 5 57 | ] 58 | removalLikelihood' matches `shouldBe` Medium 59 | it "doesn't mis-categorize allowed terms from different languages" $ do 60 | let matches = [TermMatch "t" "web/models/foo.ex" Nothing 1] 61 | removalLikelihood' matches `shouldBe` High 62 | 63 | removalLikelihood' :: [TermMatch] -> RemovalLikelihood 64 | removalLikelihood' = rLikelihood . trRemoval . calculateLikelihood config . resultsFromMatches 65 | where 66 | (Right config) = loadConfig 67 | -------------------------------------------------------------------------------- /test/Unused/ParserSpec.hs: -------------------------------------------------------------------------------- 1 | module Unused.ParserSpec where 2 | 3 | import qualified Data.Map.Strict as Map 4 | import Test.Hspec 5 | import Unused.Parser 6 | import Unused.ResultsClassifier 7 | import Unused.TermSearch 8 | import Unused.Types 9 | 10 | main :: IO () 11 | main = hspec spec 12 | 13 | spec :: Spec 14 | spec = 15 | parallel $ 16 | describe "parseResults" $ do 17 | it "parses from the correct format" $ do 18 | let r1Matches = 19 | [ TermMatch "method_name" "app/path/foo.rb" Nothing 1 20 | , TermMatch "method_name" "app/path/other.rb" Nothing 5 21 | , TermMatch "method_name" "spec/path/foo_spec.rb" Nothing 10 22 | ] 23 | let r1Results = 24 | TermResults 25 | "method_name" 26 | ["method_name"] 27 | r1Matches 28 | (Occurrences 1 10) 29 | (Occurrences 2 6) 30 | (Occurrences 3 16) 31 | (Removal Low "used frequently") 32 | Nothing 33 | let r2Matches = [TermMatch "other" "app/path/other.rb" Nothing 1] 34 | let r2Results = 35 | TermResults 36 | "other" 37 | ["other"] 38 | r2Matches 39 | (Occurrences 0 0) 40 | (Occurrences 1 1) 41 | (Occurrences 1 1) 42 | (Removal High "occurs once") 43 | Nothing 44 | let (Right config) = loadConfig 45 | let result = parseResults config $ SearchResults $ r1Matches ++ r2Matches 46 | result `shouldBe` Map.fromList [("method_name", r1Results), ("other", r2Results)] 47 | it "parses when no config is provided" $ do 48 | let r1Matches = 49 | [ TermMatch "method_name" "app/path/foo.rb" Nothing 1 50 | , TermMatch "method_name" "app/path/other.rb" Nothing 5 51 | , TermMatch "method_name" "spec/path/foo_spec.rb" Nothing 10 52 | ] 53 | let r1Results = 54 | TermResults 55 | "method_name" 56 | ["method_name"] 57 | r1Matches 58 | (Occurrences 1 10) 59 | (Occurrences 2 6) 60 | (Occurrences 3 16) 61 | (Removal Low "used frequently") 62 | Nothing 63 | let result = parseResults [] $ SearchResults r1Matches 64 | result `shouldBe` Map.fromList [("method_name", r1Results)] 65 | it "handles aliases correctly" $ do 66 | let r1Matches = [TermMatch "admin?" "app/path/user.rb" Nothing 3] 67 | let r2Matches = 68 | [ TermMatch "admin?" "spec/models/user_spec.rb" (Just "be_admin") 2 69 | , TermMatch 70 | "admin?" 71 | "spec/features/user_promoted_to_admin_spec.rb" 72 | (Just "be_admin") 73 | 2 74 | ] 75 | let (Right config) = loadConfig 76 | let searchResults = r1Matches ++ r2Matches 77 | let result = parseResults config $ SearchResults searchResults 78 | let results = 79 | TermResults 80 | "admin?" 81 | ["admin?", "be_admin"] 82 | searchResults 83 | (Occurrences 2 4) 84 | (Occurrences 1 3) 85 | (Occurrences 3 7) 86 | (Removal Low "used frequently") 87 | Nothing 88 | result `shouldBe` Map.fromList [("admin?|be_admin", results)] 89 | it "handles empty input" $ do 90 | let (Right config) = loadConfig 91 | let result = parseResults config $ SearchResults [] 92 | result `shouldBe` Map.fromList [] 93 | -------------------------------------------------------------------------------- /app/Main.hs: -------------------------------------------------------------------------------- 1 | module Main where 2 | 3 | import App (runProgram) 4 | import Common 5 | import qualified Data.Maybe as M 6 | import Options.Applicative 7 | import Types (Options(Options)) 8 | import Unused.CLI (SearchRunner(..)) 9 | import Unused.Grouping (CurrentGrouping(..)) 10 | import Unused.TermSearch (SearchBackend(..)) 11 | import Unused.Types (RemovalLikelihood(..)) 12 | import Unused.Util (stringToInt) 13 | 14 | main :: IO () 15 | main = runProgram =<< parseCLI 16 | 17 | parseCLI :: IO Options 18 | parseCLI = execParser (withInfo parseOptions pHeader pDescription pFooter) 19 | where 20 | pHeader = "Unused: Analyze potentially unused code" 21 | pDescription = 22 | "Unused allows a developer to leverage an existing tags file \ 23 | \(located at .git/tags, tags, or tmp/tags) to identify tokens \ 24 | \in a codebase that are unused." 25 | pFooter = "CLI USAGE: $ unused" 26 | 27 | withInfo :: Parser a -> String -> String -> String -> ParserInfo a 28 | withInfo opts h d f = info (helper <*> opts) $ header h <> progDesc d <> footer f 29 | 30 | parseOptions :: Parser Options 31 | parseOptions = 32 | Options <$> parseSearchRunner <*> parseDisplaySingleOccurrenceMatches <*> parseLikelihoods <*> 33 | parseAllLikelihoods <*> 34 | parseIgnorePaths <*> 35 | parseGroupings <*> 36 | parseWithoutCache <*> 37 | parseFromStdIn <*> 38 | parseCommitCount <*> 39 | parseSearchBackend 40 | 41 | parseSearchRunner :: Parser SearchRunner 42 | parseSearchRunner = 43 | flag SearchWithProgress SearchWithoutProgress $ 44 | short 'P' <> long "no-progress" <> help "Don't display progress during analysis" 45 | 46 | parseDisplaySingleOccurrenceMatches :: Parser Bool 47 | parseDisplaySingleOccurrenceMatches = 48 | switch $ short 's' <> long "single-occurrence" <> help "Display only single occurrences" 49 | 50 | parseLikelihoods :: Parser [RemovalLikelihood] 51 | parseLikelihoods = many (parseLikelihood <$> parseLikelihoodOption) 52 | 53 | parseLikelihood :: String -> RemovalLikelihood 54 | parseLikelihood "high" = High 55 | parseLikelihood "medium" = Medium 56 | parseLikelihood "low" = Low 57 | parseLikelihood _ = Unknown 58 | 59 | parseLikelihoodOption :: Parser String 60 | parseLikelihoodOption = 61 | strOption $ 62 | short 'l' <> long "likelihood" <> 63 | help "[Allows multiple] [Allowed: high, medium, low] Display results based on likelihood" 64 | 65 | parseAllLikelihoods :: Parser Bool 66 | parseAllLikelihoods = switch $ short 'a' <> long "all-likelihoods" <> help "Display all likelihoods" 67 | 68 | parseIgnorePaths :: Parser [String] 69 | parseIgnorePaths = 70 | many $ 71 | strOption $ 72 | long "ignore" <> metavar "PATH" <> help "[Allows multiple] Ignore paths that contain PATH" 73 | 74 | parseGroupings :: Parser CurrentGrouping 75 | parseGroupings = M.fromMaybe GroupByDirectory <$> maybeGroup 76 | where 77 | maybeGroup = optional $ parseGrouping <$> parseGroupingOption 78 | 79 | parseGrouping :: String -> CurrentGrouping 80 | parseGrouping "directory" = GroupByDirectory 81 | parseGrouping "term" = GroupByTerm 82 | parseGrouping "file" = GroupByFile 83 | parseGrouping "none" = NoGroup 84 | parseGrouping _ = NoGroup 85 | 86 | parseGroupingOption :: Parser String 87 | parseGroupingOption = 88 | strOption $ 89 | short 'g' <> long "group-by" <> help "[Allowed: directory, term, file, none] Group results" 90 | 91 | parseWithoutCache :: Parser Bool 92 | parseWithoutCache = 93 | switch $ short 'C' <> long "no-cache" <> help "Ignore cache when performing calculations" 94 | 95 | parseFromStdIn :: Parser Bool 96 | parseFromStdIn = switch $ long "stdin" <> help "Read tags from STDIN" 97 | 98 | parseCommitCount :: Parser (Maybe Int) 99 | parseCommitCount = (stringToInt =<<) <$> commitParser 100 | where 101 | commitParser = 102 | optional $ 103 | strOption $ long "commits" <> help "Number of recent commit SHAs to display per token" 104 | 105 | parseSearchBackend :: Parser SearchBackend 106 | parseSearchBackend = M.fromMaybe Ag <$> maybeBackend 107 | where 108 | maybeBackend = optional $ parseBackend <$> parseBackendOption 109 | parseBackendOption = 110 | strOption $ long "search" <> help "[Allowed: ag, rg] Select searching backend" 111 | 112 | parseBackend :: String -> SearchBackend 113 | parseBackend "ag" = Ag 114 | parseBackend "rg" = Rg 115 | parseBackend _ = Ag 116 | -------------------------------------------------------------------------------- /app/App.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE FlexibleContexts #-} 2 | 3 | module App 4 | ( runProgram 5 | ) where 6 | 7 | import Control.Monad.Except (runExceptT, throwError) 8 | import Control.Monad.Reader (asks, liftIO, runReaderT) 9 | import qualified Data.Bifunctor as BF 10 | import qualified Data.Bool as B 11 | import qualified Data.Maybe as M 12 | import Types 13 | import Unused.Aliases (termsAndAliases) 14 | import Unused.CLI 15 | (SearchRunner(..), executeSearch, loadGitContext, renderHeader, 16 | withRuntime) 17 | import qualified Unused.CLI.Views as V 18 | import Unused.Cache (cached) 19 | import Unused.Grouping (CurrentGrouping(..), groupedResponses) 20 | import Unused.Parser (parseResults) 21 | import Unused.ResponseFilter 22 | (ignoringPaths, withLikelihoods, withOneOccurrence) 23 | import Unused.ResultsClassifier 24 | (LanguageConfiguration(..), loadAllConfigurations) 25 | import Unused.TagsSource (loadTagsFromFile, loadTagsFromPipe) 26 | import Unused.TermSearch 27 | (SearchBackend(..), SearchResults(..), SearchTerm, fromResults) 28 | import Unused.Types (RemovalLikelihood(..), TermMatchSet) 29 | 30 | runProgram :: Options -> IO () 31 | runProgram options = 32 | withRuntime $ either renderError return =<< runExceptT (runReaderT (runApp run) options) 33 | 34 | run :: App () 35 | run = do 36 | terms <- termsWithAlternatesFromConfig 37 | liftIO $ renderHeader terms 38 | backend <- searchBackend 39 | results <- withCache . flip (executeSearch backend) terms =<< searchRunner 40 | printResults =<< retrieveGitContext =<< fmap (`parseResults` results) loadAllConfigs 41 | 42 | searchBackend :: AppConfig m => m SearchBackend 43 | searchBackend = asks oSearchBackend 44 | 45 | termsWithAlternatesFromConfig :: App [SearchTerm] 46 | termsWithAlternatesFromConfig = 47 | termsAndAliases <$> (concatMap lcTermAliases <$> loadAllConfigs) <*> calculateTagInput 48 | 49 | renderError :: AppError -> IO () 50 | renderError (TagError e) = V.missingTagsFileError e 51 | renderError (InvalidConfigError e) = V.invalidConfigError e 52 | renderError (CacheError e) = V.fingerprintError e 53 | 54 | retrieveGitContext :: TermMatchSet -> App TermMatchSet 55 | retrieveGitContext tms = maybe (return tms) (liftIO . flip loadGitContext tms) =<< numberOfCommits 56 | 57 | printResults :: TermMatchSet -> App () 58 | printResults tms = do 59 | filters <- optionFilters tms 60 | grouping <- groupingOptions 61 | formatter <- resultFormatter 62 | liftIO $ V.searchResults formatter $ groupedResponses grouping filters 63 | 64 | loadAllConfigs :: App [LanguageConfiguration] 65 | loadAllConfigs = 66 | either throwError return =<< BF.first InvalidConfigError <$> liftIO loadAllConfigurations 67 | 68 | calculateTagInput :: App [String] 69 | calculateTagInput = 70 | either throwError return =<< 71 | liftIO . fmap (BF.first TagError) . B.bool loadTagsFromFile loadTagsFromPipe =<< readFromStdIn 72 | 73 | withCache :: IO SearchResults -> App SearchResults 74 | withCache f = B.bool (liftIO f) (withCache' f) =<< runWithCache 75 | where 76 | withCache' :: IO SearchResults -> App SearchResults 77 | withCache' r = 78 | either (throwError . CacheError) (return . SearchResults) =<< 79 | liftIO (cached "term-matches" $ fmap fromResults r) 80 | 81 | optionFilters :: AppConfig m => TermMatchSet -> m TermMatchSet 82 | optionFilters tms = foldl (>>=) (pure tms) matchSetFilters 83 | where 84 | matchSetFilters = [singleOccurrenceFilter, likelihoodsFilter, ignoredPathsFilter] 85 | 86 | singleOccurrenceFilter :: AppConfig m => TermMatchSet -> m TermMatchSet 87 | singleOccurrenceFilter tms = B.bool tms (withOneOccurrence tms) <$> asks oSingleOccurrenceMatches 88 | 89 | likelihoodsFilter :: AppConfig m => TermMatchSet -> m TermMatchSet 90 | likelihoodsFilter tms = asks $ withLikelihoods . likelihoods <*> pure tms 91 | where 92 | likelihoods options 93 | | oAllLikelihoods options = [High, Medium, Low] 94 | | null $ oLikelihoods options = [High] 95 | | otherwise = oLikelihoods options 96 | 97 | ignoredPathsFilter :: AppConfig m => TermMatchSet -> m TermMatchSet 98 | ignoredPathsFilter tms = asks $ ignoringPaths . oIgnoredPaths <*> pure tms 99 | 100 | readFromStdIn :: AppConfig m => m Bool 101 | readFromStdIn = asks oFromStdIn 102 | 103 | groupingOptions :: AppConfig m => m CurrentGrouping 104 | groupingOptions = asks oGrouping 105 | 106 | searchRunner :: AppConfig m => m SearchRunner 107 | searchRunner = asks oSearchRunner 108 | 109 | runWithCache :: AppConfig m => m Bool 110 | runWithCache = asks $ not . oWithoutCache 111 | 112 | numberOfCommits :: AppConfig m => m (Maybe Int) 113 | numberOfCommits = asks oCommitCount 114 | 115 | resultFormatter :: AppConfig m => m V.ResultsFormat 116 | resultFormatter = B.bool V.Column V.List . M.isJust <$> numberOfCommits 117 | -------------------------------------------------------------------------------- /src/Unused/Types.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE DeriveGeneric #-} 2 | 3 | module Unused.Types 4 | ( SearchTerm(..) 5 | , TermMatch(..) 6 | , TermResults(..) 7 | , TermMatchSet 8 | , RemovalLikelihood(..) 9 | , Removal(..) 10 | , Occurrences(..) 11 | , GitContext(..) 12 | , GitCommit(..) 13 | , searchTermToString 14 | , resultsFromMatches 15 | , tmDisplayTerm 16 | , totalFileCount 17 | , totalOccurrenceCount 18 | , appOccurrenceCount 19 | , removalLikelihood 20 | , resultAliases 21 | ) where 22 | 23 | import Control.Monad (liftM2) 24 | import Data.Csv (FromRecord, ToRecord) 25 | import qualified Data.List as L 26 | import qualified Data.Map.Strict as Map 27 | import qualified Data.Maybe as M 28 | import qualified GHC.Generics as G 29 | import qualified Unused.Regex as R 30 | 31 | data SearchTerm 32 | = OriginalTerm String 33 | | AliasTerm String 34 | String 35 | deriving (Eq, Show) 36 | 37 | searchTermToString :: SearchTerm -> String 38 | searchTermToString (OriginalTerm s) = s 39 | searchTermToString (AliasTerm _ a) = a 40 | 41 | data TermMatch = TermMatch 42 | { tmTerm :: String 43 | , tmPath :: String 44 | , tmAlias :: Maybe String 45 | , tmOccurrences :: Int 46 | } deriving (Eq, Show, G.Generic) 47 | 48 | instance FromRecord TermMatch 49 | 50 | instance ToRecord TermMatch 51 | 52 | data Occurrences = Occurrences 53 | { oFiles :: Int 54 | , oOccurrences :: Int 55 | } deriving (Eq, Show) 56 | 57 | data TermResults = TermResults 58 | { trTerm :: String 59 | , trTerms :: [String] 60 | , trMatches :: [TermMatch] 61 | , trTestOccurrences :: Occurrences 62 | , trAppOccurrences :: Occurrences 63 | , trTotalOccurrences :: Occurrences 64 | , trRemoval :: Removal 65 | , trGitContext :: Maybe GitContext 66 | } deriving (Eq, Show) 67 | 68 | data Removal = Removal 69 | { rLikelihood :: RemovalLikelihood 70 | , rReason :: String 71 | } deriving (Eq, Show) 72 | 73 | newtype GitContext = GitContext 74 | { gcCommits :: [GitCommit] 75 | } deriving (Eq, Show) 76 | 77 | newtype GitCommit = GitCommit 78 | { gcSha :: String 79 | } deriving (Eq, Show) 80 | 81 | data RemovalLikelihood 82 | = High 83 | | Medium 84 | | Low 85 | | Unknown 86 | | NotCalculated 87 | deriving (Eq, Show) 88 | 89 | type TermMatchSet = Map.Map String TermResults 90 | 91 | totalFileCount :: TermResults -> Int 92 | totalFileCount = oFiles . trTotalOccurrences 93 | 94 | totalOccurrenceCount :: TermResults -> Int 95 | totalOccurrenceCount = oOccurrences . trTotalOccurrences 96 | 97 | appOccurrenceCount :: TermResults -> Int 98 | appOccurrenceCount = oOccurrences . trAppOccurrences 99 | 100 | removalLikelihood :: TermResults -> RemovalLikelihood 101 | removalLikelihood = rLikelihood . trRemoval 102 | 103 | resultAliases :: TermResults -> [String] 104 | resultAliases = trTerms 105 | 106 | tmDisplayTerm :: TermMatch -> String 107 | tmDisplayTerm = liftM2 M.fromMaybe tmTerm tmAlias 108 | 109 | resultsFromMatches :: [TermMatch] -> TermResults 110 | resultsFromMatches tms = 111 | TermResults 112 | { trTerm = resultTerm terms 113 | , trTerms = L.sort $ L.nub terms 114 | , trMatches = tms 115 | , trAppOccurrences = appOccurrence 116 | , trTestOccurrences = testOccurrence 117 | , trTotalOccurrences = 118 | Occurrences 119 | (sum $ map oFiles [appOccurrence, testOccurrence]) 120 | (sum $ map oOccurrences [appOccurrence, testOccurrence]) 121 | , trRemoval = Removal NotCalculated "Likelihood not calculated" 122 | , trGitContext = Nothing 123 | } 124 | where 125 | testOccurrence = testOccurrences tms 126 | appOccurrence = appOccurrences tms 127 | terms = map tmDisplayTerm tms 128 | resultTerm (x:_) = x 129 | resultTerm _ = "" 130 | 131 | appOccurrences :: [TermMatch] -> Occurrences 132 | appOccurrences ms = Occurrences appFiles appOccurrences' 133 | where 134 | totalFiles = length $ L.nub $ map tmPath ms 135 | totalOccurrences = sum $ map tmOccurrences ms 136 | tests = testOccurrences ms 137 | appFiles = totalFiles - oFiles tests 138 | appOccurrences' = totalOccurrences - oOccurrences tests 139 | 140 | testOccurrences :: [TermMatch] -> Occurrences 141 | testOccurrences ms = Occurrences totalFiles totalOccurrences 142 | where 143 | testMatches = filter termMatchIsTest ms 144 | totalFiles = length $ L.nub $ map tmPath testMatches 145 | totalOccurrences = sum $ map tmOccurrences testMatches 146 | 147 | testDir :: String -> Bool 148 | testDir = R.matchRegex "(spec|tests?|features)\\/" 149 | 150 | testSnakeCaseFilename :: String -> Bool 151 | testSnakeCaseFilename = R.matchRegex ".*(_spec|_test)\\." 152 | 153 | testCamelCaseFilename :: String -> Bool 154 | testCamelCaseFilename = R.matchRegex ".*(Spec|Test)\\." 155 | 156 | termMatchIsTest :: TermMatch -> Bool 157 | termMatchIsTest TermMatch {tmPath = path} = 158 | testDir path || testSnakeCaseFilename path || testCamelCaseFilename path 159 | -------------------------------------------------------------------------------- /unused.cabal: -------------------------------------------------------------------------------- 1 | name: unused 2 | version: 0.10.0.0 3 | synopsis: A command line tool to identify unused code. 4 | description: Please see README.md 5 | homepage: https://github.com/joshuaclayton/unused#readme 6 | license: MIT 7 | license-file: LICENSE 8 | author: Josh Clayton 9 | maintainer: sayhi@joshuaclayton.me 10 | copyright: 2016-2018 Josh Clayton 11 | category: CLI 12 | build-type: Simple 13 | -- extra-source-files: 14 | cabal-version: >=1.10 15 | data-files: data/config.yml 16 | 17 | library 18 | hs-source-dirs: src 19 | exposed-modules: Unused.TermSearch 20 | , Unused.TermSearch.Types 21 | , Unused.TermSearch.Internal 22 | , Unused.Parser 23 | , Unused.Types 24 | , Unused.GitContext 25 | , Unused.Util 26 | , Unused.Regex 27 | , Unused.Aliases 28 | , Unused.Projection 29 | , Unused.Projection.Transform 30 | , Unused.ResponseFilter 31 | , Unused.ResultsClassifier 32 | , Unused.ResultsClassifier.Types 33 | , Unused.ResultsClassifier.Config 34 | , Unused.Grouping 35 | , Unused.Grouping.Internal 36 | , Unused.Grouping.Types 37 | , Unused.LikelihoodCalculator 38 | , Unused.Cache 39 | , Unused.Cache.DirectoryFingerprint 40 | , Unused.Cache.FindArgsFromIgnoredPaths 41 | , Unused.TagsSource 42 | , Unused.CLI 43 | , Unused.CLI.Search 44 | , Unused.CLI.GitContext 45 | , Unused.CLI.Util 46 | , Unused.CLI.Views 47 | , Unused.CLI.Views.Error 48 | , Unused.CLI.Views.NoResultsFound 49 | , Unused.CLI.Views.AnalysisHeader 50 | , Unused.CLI.Views.GitSHAsHeader 51 | , Unused.CLI.Views.MissingTagsFileError 52 | , Unused.CLI.Views.InvalidConfigError 53 | , Unused.CLI.Views.FingerprintError 54 | , Unused.CLI.Views.SearchResult 55 | , Unused.CLI.Views.SearchResult.ColumnFormatter 56 | , Unused.CLI.Views.SearchResult.Internal 57 | , Unused.CLI.Views.SearchResult.ListResult 58 | , Unused.CLI.Views.SearchResult.TableResult 59 | , Unused.CLI.Views.SearchResult.Types 60 | , Unused.CLI.ProgressIndicator 61 | , Unused.CLI.ProgressIndicator.Internal 62 | , Unused.CLI.ProgressIndicator.Types 63 | , Common 64 | build-depends: base >= 4.7 && < 5 65 | , process 66 | , containers 67 | , filepath 68 | , directory 69 | , regex-tdfa 70 | , terminal-progress-bar >= 0.1.1.1 && < 0.1.2 71 | , ansi-terminal 72 | , unix 73 | , parallel-io 74 | , yaml 75 | , bytestring 76 | , text 77 | , unordered-containers 78 | , cassava >= 0.5.1.0 && < 0.6 79 | , vector 80 | , mtl 81 | , transformers 82 | , megaparsec >= 7.0.5 && < 8 83 | , inflections >= 0.4.0.3 && < 0.5 84 | , file-embed 85 | ghc-options: -Wall 86 | default-language: Haskell2010 87 | default-extensions: OverloadedStrings 88 | 89 | executable unused 90 | hs-source-dirs: app 91 | main-is: Main.hs 92 | ghc-options: -threaded -rtsopts -with-rtsopts=-N -Wall 93 | build-depends: base 94 | , unused 95 | , optparse-applicative 96 | , mtl 97 | , transformers 98 | other-modules: App 99 | , Types 100 | default-language: Haskell2010 101 | 102 | test-suite unused-test 103 | type: exitcode-stdio-1.0 104 | hs-source-dirs: test 105 | main-is: Spec.hs 106 | build-depends: base 107 | , unused 108 | , hspec 109 | , containers 110 | , text 111 | other-modules: Unused.ParserSpec 112 | , Unused.ResponseFilterSpec 113 | , Unused.TypesSpec 114 | , Unused.LikelihoodCalculatorSpec 115 | , Unused.Grouping.InternalSpec 116 | , Unused.TermSearch.InternalSpec 117 | , Unused.UtilSpec 118 | , Unused.Cache.FindArgsFromIgnoredPathsSpec 119 | , Unused.AliasesSpec 120 | , Unused.ProjectionSpec 121 | ghc-options: -threaded -rtsopts -with-rtsopts=-N -Wall 122 | default-language: Haskell2010 123 | default-extensions: OverloadedStrings 124 | 125 | source-repository head 126 | type: git 127 | location: https://github.com/joshuaclayton/unused 128 | -------------------------------------------------------------------------------- /src/Unused/ResultsClassifier/Types.hs: -------------------------------------------------------------------------------- 1 | {-# LANGUAGE FlexibleInstances #-} 2 | 3 | module Unused.ResultsClassifier.Types 4 | ( LanguageConfiguration(..) 5 | , LowLikelihoodMatch(..) 6 | , TermAlias(..) 7 | , Position(..) 8 | , Matcher(..) 9 | , ParseConfigError(..) 10 | ) where 11 | 12 | import qualified Control.Applicative as A 13 | import qualified Control.Monad as M 14 | import qualified Data.HashMap.Strict as HM 15 | import qualified Data.List as L 16 | import Data.Text (Text) 17 | import qualified Data.Text as T 18 | import Data.Yaml (FromJSON(..), (.:), (.:?), (.!=)) 19 | import qualified Data.Yaml as Y 20 | import Unused.Projection 21 | 22 | data LanguageConfiguration = LanguageConfiguration 23 | { lcName :: String 24 | , lcAllowedTerms :: [String] 25 | , lcAutoLowLikelihood :: [LowLikelihoodMatch] 26 | , lcTermAliases :: [TermAlias] 27 | } 28 | 29 | data LowLikelihoodMatch = LowLikelihoodMatch 30 | { smName :: String 31 | , smMatchers :: [Matcher] 32 | , smClassOrModule :: Bool 33 | } 34 | 35 | data TermAlias = TermAlias 36 | { taFrom :: String 37 | , taTo :: String 38 | , taTransform :: Text -> Text 39 | } 40 | 41 | data ParseConfigError = ParseConfigError 42 | { pcePath :: String 43 | , pceParseError :: String 44 | } 45 | 46 | data Position = StartsWith | EndsWith | Equals 47 | data Matcher = Term Position String | Path Position String | AppOccurrences Int | AllowedTerms [String] 48 | 49 | instance FromJSON LanguageConfiguration where 50 | parseJSON (Y.Object o) = LanguageConfiguration 51 | <$> o .: "name" 52 | <*> o .:? "allowedTerms" .!= [] 53 | <*> o .:? "autoLowLikelihood" .!= [] 54 | <*> o .:? "aliases" .!= [] 55 | parseJSON _ = M.mzero 56 | 57 | instance FromJSON LowLikelihoodMatch where 58 | parseJSON (Y.Object o) = LowLikelihoodMatch 59 | <$> o .: "name" 60 | <*> parseMatchers o 61 | <*> o .:? "classOrModule" .!= False 62 | parseJSON _ = M.mzero 63 | 64 | instance FromJSON TermAlias where 65 | parseJSON (Y.Object o) = TermAlias 66 | <$> o .: "from" 67 | <*> o .: "to" 68 | <*> (either fail return =<< (translate . T.pack <$> (o .: "to"))) 69 | parseJSON _ = M.mzero 70 | 71 | data MatchHandler a = MatchHandler 72 | { mhKeys :: [String] 73 | , mhKeyToMatcher :: T.Text -> Either T.Text (a -> Matcher) 74 | } 75 | 76 | intHandler :: MatchHandler Int 77 | intHandler = MatchHandler 78 | { mhKeys = ["appOccurrences"] 79 | , mhKeyToMatcher = keyToMatcher 80 | } 81 | where 82 | keyToMatcher "appOccurrences" = Right AppOccurrences 83 | keyToMatcher t = Left t 84 | 85 | stringHandler :: MatchHandler String 86 | stringHandler = MatchHandler 87 | { mhKeys = ["pathStartsWith", "pathEndsWith", "termStartsWith", "termEndsWith", "termEquals"] 88 | , mhKeyToMatcher = keyToMatcher 89 | } 90 | where 91 | keyToMatcher "pathStartsWith" = Right $ Path StartsWith 92 | keyToMatcher "pathEndsWith" = Right $ Path EndsWith 93 | keyToMatcher "termStartsWith" = Right $ Term StartsWith 94 | keyToMatcher "termEndsWith" = Right $ Term EndsWith 95 | keyToMatcher "termEquals" = Right $ Term Equals 96 | keyToMatcher t = Left t 97 | 98 | stringListHandler :: MatchHandler [String] 99 | stringListHandler = MatchHandler 100 | { mhKeys = ["allowedTerms"] 101 | , mhKeyToMatcher = keyToMatcher 102 | } 103 | where 104 | keyToMatcher "allowedTerms" = Right AllowedTerms 105 | keyToMatcher t = Left t 106 | 107 | lowLikelihoodMatchKeys :: [T.Text] 108 | lowLikelihoodMatchKeys = 109 | map T.pack $ ["name", "classOrModule"] ++ mhKeys intHandler ++ mhKeys stringHandler ++ mhKeys stringListHandler 110 | 111 | validateLowLikelihoodKeys :: Y.Object -> Y.Parser [Matcher] -> Y.Parser [Matcher] 112 | validateLowLikelihoodKeys o ms = 113 | if fullOverlap 114 | then ms 115 | else fail $ "The following keys are unsupported: " ++ L.intercalate ", " (T.unpack <$> unsupportedKeys) 116 | where 117 | fullOverlap = null unsupportedKeys 118 | unsupportedKeys = HM.keys o L.\\ lowLikelihoodMatchKeys 119 | 120 | parseMatchers :: Y.Object -> Y.Parser [Matcher] 121 | parseMatchers o = 122 | validateLowLikelihoodKeys o $ myFold (++) [buildMatcherList o intHandler, buildMatcherList o stringHandler, buildMatcherList o stringListHandler] 123 | where 124 | myFold :: (Foldable t, Monad m) => (a -> a -> a) -> t (m a) -> m a 125 | myFold f = foldl1 (\acc i -> acc >>= (\l -> f l <$> i)) 126 | 127 | buildMatcherList :: FromJSON a => Y.Object -> MatchHandler a -> Y.Parser [Matcher] 128 | buildMatcherList o mh = 129 | sequenceA $ matcherParserForKey <$> keysToParse 130 | where 131 | matcherParserForKey k = extractMatcher (mhKeyToMatcher mh k) $ mKey k 132 | keysToParse = positionKeysforMatcher o (mhKeys mh) 133 | mKey = (.:?) o 134 | 135 | positionKeysforMatcher :: Y.Object -> [String] -> [T.Text] 136 | positionKeysforMatcher o ls = L.intersect (T.pack <$> ls) $ HM.keys o 137 | 138 | extractMatcher :: Either T.Text (a -> Matcher) -> Y.Parser (Maybe a) -> Y.Parser Matcher 139 | extractMatcher e p = either displayFailure (convertFoundObjectToMatcher p) e 140 | 141 | convertFoundObjectToMatcher :: (Monad m, A.Alternative m) => m (Maybe a) -> (a -> b) -> m b 142 | convertFoundObjectToMatcher p f = maybe A.empty (pure . f) =<< p 143 | 144 | displayFailure :: T.Text -> Y.Parser a 145 | displayFailure t = fail $ "Parse error: '" ++ T.unpack t ++ "' is not a valid key in a singleOnly matcher" 146 | -------------------------------------------------------------------------------- /test/Unused/ResponseFilterSpec.hs: -------------------------------------------------------------------------------- 1 | module Unused.ResponseFilterSpec 2 | ( main 3 | , spec 4 | ) where 5 | 6 | import Data.List (find) 7 | import Test.Hspec 8 | import Unused.ResponseFilter 9 | import Unused.ResultsClassifier 10 | import Unused.Types 11 | (TermMatch(..), TermResults, resultsFromMatches) 12 | 13 | main :: IO () 14 | main = hspec spec 15 | 16 | spec :: Spec 17 | spec = 18 | parallel $ do 19 | describe "railsAutoLowLikelihood" $ do 20 | it "allows controllers" $ do 21 | let match = 22 | TermMatch 23 | "ApplicationController" 24 | "app/controllers/application_controller.rb" 25 | Nothing 26 | 1 27 | let result = resultsFromMatches [match] 28 | railsAutoLowLikelihood result `shouldBe` True 29 | it "allows helpers" $ do 30 | let match = 31 | TermMatch "ApplicationHelper" "app/helpers/application_helper.rb" Nothing 1 32 | let result = resultsFromMatches [match] 33 | railsAutoLowLikelihood result `shouldBe` True 34 | it "allows migrations" $ do 35 | let match = 36 | TermMatch 37 | "CreateUsers" 38 | "db/migrate/20160101120000_create_users.rb" 39 | Nothing 40 | 1 41 | let result = resultsFromMatches [match] 42 | railsAutoLowLikelihood result `shouldBe` True 43 | it "disallows service objects" $ do 44 | let match = 45 | TermMatch 46 | "CreatePostWithNotifications" 47 | "app/services/create_post_with_notifications.rb" 48 | Nothing 49 | 1 50 | let result = resultsFromMatches [match] 51 | railsAutoLowLikelihood result `shouldBe` False 52 | it "disallows methods" $ do 53 | let match = 54 | TermMatch 55 | "my_method" 56 | "app/services/create_post_with_notifications.rb" 57 | Nothing 58 | 1 59 | let result = resultsFromMatches [match] 60 | railsAutoLowLikelihood result `shouldBe` False 61 | it "disallows models that occur in migrations" $ do 62 | let model = TermMatch "User" "app/models/user.rb" Nothing 1 63 | let migration = 64 | TermMatch "User" "db/migrate/20160101120000_create_users.rb" Nothing 1 65 | let result = resultsFromMatches [model, migration] 66 | railsAutoLowLikelihood result `shouldBe` False 67 | it "allows matches intermixed with other results" $ do 68 | let appToken = 69 | TermMatch "ApplicationHelper" "app/helpers/application_helper.rb" Nothing 1 70 | let testToken = 71 | TermMatch 72 | "ApplicationHelper" 73 | "spec/helpers/application_helper_spec.rb" 74 | Nothing 75 | 10 76 | let result = resultsFromMatches [appToken, testToken] 77 | railsAutoLowLikelihood result `shouldBe` True 78 | describe "elixirAutoLowLikelihood" $ do 79 | it "disallows controllers" $ do 80 | let match = 81 | TermMatch "PageController" "web/controllers/page_controller.rb" Nothing 1 82 | let result = resultsFromMatches [match] 83 | elixirAutoLowLikelihood result `shouldBe` False 84 | it "allows views" $ do 85 | let match = TermMatch "PageView" "web/views/page_view.rb" Nothing 1 86 | let result = resultsFromMatches [match] 87 | elixirAutoLowLikelihood result `shouldBe` True 88 | it "allows migrations" $ do 89 | let match = 90 | TermMatch 91 | "CreateUsers" 92 | "priv/repo/migrations/20160101120000_create_users.exs" 93 | Nothing 94 | 1 95 | let result = resultsFromMatches [match] 96 | elixirAutoLowLikelihood result `shouldBe` True 97 | it "allows tests" $ do 98 | let match = TermMatch "UserTest" "test/models/user_test.exs" Nothing 1 99 | let result = resultsFromMatches [match] 100 | elixirAutoLowLikelihood result `shouldBe` True 101 | it "allows Mixfile" $ do 102 | let match = TermMatch "Mixfile" "mix.exs" Nothing 1 103 | let result = resultsFromMatches [match] 104 | elixirAutoLowLikelihood result `shouldBe` True 105 | it "allows __using__" $ do 106 | let match = TermMatch "__using__" "web/web.ex" Nothing 1 107 | let result = resultsFromMatches [match] 108 | elixirAutoLowLikelihood result `shouldBe` True 109 | it "disallows service modules" $ do 110 | let match = 111 | TermMatch 112 | "CreatePostWithNotifications" 113 | "web/services/create_post_with_notifications.ex" 114 | Nothing 115 | 1 116 | let result = resultsFromMatches [match] 117 | elixirAutoLowLikelihood result `shouldBe` False 118 | it "disallows functions" $ do 119 | let match = 120 | TermMatch 121 | "my_function" 122 | "web/services/create_post_with_notifications.ex" 123 | Nothing 124 | 1 125 | let result = resultsFromMatches [match] 126 | elixirAutoLowLikelihood result `shouldBe` False 127 | it "allows matches intermixed with other results" $ do 128 | let appToken = TermMatch "UserView" "web/views/user_view.ex" Nothing 1 129 | let testToken = TermMatch "UserView" "test/views/user_view_test.exs" Nothing 10 130 | let result = resultsFromMatches [appToken, testToken] 131 | elixirAutoLowLikelihood result `shouldBe` True 132 | describe "haskellAutoLowLikelihood" $ do 133 | it "allows instance" $ do 134 | let match = TermMatch "instance" "src/Lib/Types.hs" Nothing 1 135 | let result = resultsFromMatches [match] 136 | haskellAutoLowLikelihood result `shouldBe` True 137 | it "allows items in the *.cabal file" $ do 138 | let match = TermMatch "Lib.SomethingSpec" "lib.cabal" Nothing 1 139 | let result = resultsFromMatches [match] 140 | haskellAutoLowLikelihood result `shouldBe` True 141 | describe "autoLowLikelihood" $ 142 | it "doesn't qualify as low when no matchers are present in a language config" $ do 143 | let match = TermMatch "AwesomeThing" "app/foo/awesome_thing.rb" Nothing 1 144 | let result = resultsFromMatches [match] 145 | let languageConfig = 146 | LanguageConfiguration 147 | "Bad" 148 | [] 149 | [LowLikelihoodMatch "Match with empty matchers" [] False] 150 | [] 151 | autoLowLikelihood languageConfig result `shouldBe` False 152 | 153 | configByName :: String -> LanguageConfiguration 154 | configByName s = config' 155 | where 156 | (Right config) = loadConfig 157 | (Just config') = find ((==) s . lcName) config 158 | 159 | railsAutoLowLikelihood :: TermResults -> Bool 160 | railsAutoLowLikelihood = autoLowLikelihood (configByName "Rails") 161 | 162 | elixirAutoLowLikelihood :: TermResults -> Bool 163 | elixirAutoLowLikelihood = autoLowLikelihood (configByName "Phoenix") 164 | 165 | haskellAutoLowLikelihood :: TermResults -> Bool 166 | haskellAutoLowLikelihood = autoLowLikelihood (configByName "Haskell") 167 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Unused [![Build Status](https://travis-ci.org/joshuaclayton/unused.svg?branch=master)](https://travis-ci.org/joshuaclayton/unused) 2 | 3 | A command line tool to identify unused code. 4 | 5 | # NOTICE: As of May 27, 2020, this version of Unused has been deprecated 6 | 7 | Unused has been rewritten and now lives at https://github.com/unused-code/unused 8 | 9 | Issues, updates, and all future work will occur there. 10 | 11 | ![Image of Unused Output](http://i.giphy.com/3o7qDT1I4OfQxnJTvW.gif) 12 | 13 | ## "What kinds of projects can I used it on?" 14 | 15 | Anything. 16 | 17 | Yes, literally anything. 18 | 19 | It's probably best if you have a file generated from `ctags` it can read from 20 | (it looks in `.git`, `tmp`, and the root directory for a `tags` file), but if 21 | you have another way to pipe a bunch of 22 | methods/functions/classes/modules/whatever in, that works too. 23 | 24 | Right now, there are some special cases built in for Rails and Phoenix apps 25 | (specifically, assumptions about what's fine to only have one reference to, 26 | e.g. Controllers in Rails and Views in Phoenix), but it'll work on Rubygems, 27 | Elixir packages, or anything else. 28 | 29 | That said, **be confident the code you're removing won't break your program**. 30 | Especially with projects built in Ruby, Elixir, or JavaScript, there are ways 31 | to dynamically trigger or define behavior that may be surprising. A test suite 32 | can help here, but still cannot determine every possible execution path. 33 | 34 | ## Installing and Updating 35 | 36 | ### Homebrew (Recommended) 37 | 38 | You can install [my formulae] via [Homebrew] with `brew tap`: 39 | 40 | ```sh 41 | brew tap joshuaclayton/formulae 42 | ``` 43 | 44 | Next, run: 45 | 46 | ```sh 47 | brew install unused 48 | ``` 49 | 50 | [my formulae]: https://github.com/joshuaclayton/homebrew-formulae 51 | [Homebrew]: http://brew.sh/ 52 | 53 | This will install `unused` and its corresponding dependencies. 54 | 55 | To update, run: 56 | 57 | ```sh 58 | brew update 59 | brew upgrade unused 60 | ``` 61 | 62 | Alternatively, you can install with [Stack] or by hand. Because it needs to compile, installation times may vary, but it's often several minutes. 63 | 64 | ### Stack 65 | 66 | If you already have [Stack] installed, ensure you have the latest list of 67 | packages: 68 | 69 | ```sh 70 | stack update 71 | ``` 72 | 73 | Verify Stack is using at least `lts-6.0` when installing by checking the 74 | global project settings in `~/.stack/global-project/stack.yaml`. 75 | 76 | Once that is complete, run: 77 | 78 | ```sh 79 | stack install unused 80 | ``` 81 | 82 | This will install unused in the appropriate directory for Stack; you'll want 83 | to ensure your `$PATH` reflects this. 84 | 85 | ### Installing by hand 86 | 87 | This project is written in [Haskell] and uses [Stack]. 88 | 89 | Once you have these tools installed and the project cloned locally: 90 | 91 | ```sh 92 | stack setup 93 | stack install 94 | ``` 95 | 96 | This will generate a binary in `$HOME/.local/bin`; ensure this directory is in 97 | your `$PATH`. 98 | 99 | [Haskell]: https://www.haskell.org 100 | [Stack]: http://www.haskellstack.org 101 | 102 | ### Install via Docker 103 | 104 | Once [Docker is installed], create a binary within your `$PATH` to run the 105 | image: 106 | 107 | ```bash 108 | #!/usr/bin/env bash 109 | 110 | docker run --rm -it -v $(pwd):/code joshuaclayton/unused unused $@ 111 | ``` 112 | 113 | Note that, because Unused will be running inside of a virtual machine, it will 114 | take longer to generate output than were you to install via 115 | previously-mentioned methods. 116 | 117 | [Docker is installed]: https://docs.docker.com/engine/installation 118 | 119 | ## Using Unused 120 | 121 | `unused` attempts to read from common tags file locations (`.git/tags`, 122 | `tags`, and `tmp/tags`). 123 | 124 | In an application where the tags file exists, run: 125 | 126 | ```sh 127 | unused 128 | ``` 129 | 130 | If you don't have a tags file, you can generate one by running: 131 | ```sh 132 | git ls-files | xargs ctags 133 | ``` 134 | 135 | If you want to specify a custom tags file, or load tokens from somewhere else, 136 | run: 137 | 138 | ```sh 139 | cat .custom/tags | unused --stdin 140 | ``` 141 | 142 | To view more usage options, run: 143 | 144 | ```sh 145 | unused --help 146 | ``` 147 | 148 | ## Troubleshooting 149 | 150 | ### Ctags (and a corresponding workflow) isn't configured 151 | 152 | [Exuberant Ctags] \(or another tool that will generate a tags file, like 153 | [hasktags] for Haskell projects) is required to use `unused` correctly; 154 | however, the version of `ctags` that ships with OS X (`/usr/bin/ctags`) is an 155 | older version won't work with many languages (that BSD version of `ctags` says 156 | it "makes a tags file for ex(1) from the specified C, Pascal, Fortran, YACC, 157 | lex, and lisp sources.") 158 | 159 | [hasktags]: https://hackage.haskell.org/package/hasktags 160 | 161 | Installation via Homebrew includes the `ctags` dependency. You can also run 162 | `brew install ctags` by hand. If you're not on OS X, use your favorite package 163 | manager and refer to the [Exuberant Ctags] site for download instructions. 164 | 165 | [Exuberant Ctags]: http://ctags.sourceforge.net/ 166 | 167 | #### Ctags manual run 168 | 169 | If you're using `ctags` to generate a `tags` file prior to running `unused` and 170 | don't have a workflow around automatically generating a `tags` file, run: 171 | 172 | ```sh 173 | git ls-files | xargs ctags -f tmp/tags 174 | ``` 175 | 176 | This will take your `.gitignore` into account and write the tags file to 177 | `tmp/tags`. Be sure to write this to a location that's ignored by `git`. 178 | 179 | While this process allows a developer to get started, it requires remembering 180 | to run this command before running `unused`. Let's explore how to automate this 181 | process. 182 | 183 | #### Ctags automatic runs via `git` hooks 184 | 185 | With `ctags` installed, you'll likely want to configure your workflow such that 186 | your tags file gets updated periodically without any action on your part. I 187 | recommend following the [instructions outlined by Tim Pope] on this matter, 188 | which discusses a workflow coupled to `git` for managing the tags file. It 189 | includes shell scripting that may not look "effortless"; however, the fact this 190 | is automated helps to ensure `unused` is running against new versions of the 191 | code as you (and other teammates, if you have any) are committing. 192 | 193 | As he suggests, you'll want to run `git init` into the directories you want 194 | this hook, and to manually run the hook: 195 | 196 | ```sh 197 | git ctags 198 | ``` 199 | 200 | `unused` is configured to look for a tags file in three different directories, 201 | including `.git/` as the article suggests, so no further configuration will be 202 | necessary with `unused`. 203 | 204 | [instructions outlined by Tim Pope]: http://tbaggery.com/2011/08/08/effortless-ctags-with-git.html 205 | 206 | ### "Calculating cache fingerprint" takes a long time 207 | 208 | `unused` attempts to be intelligent at understanding if your codebase has 209 | changed before running analysis (since it can be time-consuming on large 210 | codebases). To do so, it calculates a "fingerprint" of the entire directory by 211 | using `md5` (or `md5sum`), along with `find` and your `.gitignore` file. 212 | 213 | If you're checking in artifacts (e.g. `node_modules/`, `dist/`, `tmp/`, or 214 | similar), `unused` will likely take significantly longer to calculate the 215 | fingerprint. 216 | 217 | Per the `--help` documentation, you can disable caching with the `-C` flag: 218 | 219 | ```sh 220 | $ unused -C 221 | ``` 222 | 223 | ### "No results found" when expecting results 224 | 225 | If you're expecting to see results but `unused` doesn't find anything, verify 226 | that any artifacts `unused` uses (e.g. the `tags` file, wherever it's located) 227 | or generates (e.g. in `PROJECT_ROOT/tmp/unused`) is `.gitignore`d. 228 | 229 | What might be happening is, because unused searches for tokens with `ag` 230 | (which honors `.gitignore`), it's running into checked-in versions of the 231 | tokens from other files, resulting in duplicate occurrences that aren't 232 | representative of the actual codebase. The most obvious might be the `tags` 233 | file itself, although if you're using an IDE that runs any sort of analysis 234 | and that's getting checked in somehow, that may cause it too. 235 | 236 | One final piece to check is the number of tokens in the tags file itself; if 237 | `ctags` is misconfigured and only a handful of tokens are being analyzed, they 238 | all may have low removal likelihood and not display in the default results 239 | (high-likelihood only). 240 | 241 | ### Analysis takes a long time due to a large number of terms found 242 | 243 | In my experience, projects under 100,000LOC should have at most around 8,000 244 | unique tokens found. This obviously depends on how you structure your 245 | classes/modules/functions, but it'll likely be close. 246 | 247 | If you're seeing more than 15,000 terms matched (I've seen upwards of 70,000), 248 | this is very likely due to misconfiguration of `ctags` where it includes some 249 | amount of build artifacts. In Ruby, this might be a `RAILS_ROOT/vendor` 250 | directory, or if you're using NPM, `APP_ROOT/node_modules` or 251 | `APP_ROOT/bower_components`. 252 | 253 | When configuring `ctags`, be sure to include your `--exclude` directives; you 254 | can [find an example here]. 255 | 256 | [find an example here]: https://github.com/joshuaclayton/dotfiles/commit/edf35f2a3ca2204a7c6796c3685b7da34bddf5fb#diff-6d7e423e99befb791a7db6ae51126747R76 257 | 258 | ## Custom Configuration 259 | 260 | The first time you use `unused`, you might see a handful of false positives. 261 | `unused` will look in two additional locations in an attempt to load 262 | additional custom configuration to help improve this. 263 | 264 | ### Configuration format 265 | 266 | ```yaml 267 | # Language or framework name 268 | # e.g. Rails, Ruby, Go, Play 269 | - name: Framework or language 270 | # Collection of matches allowed to have one occurrence 271 | autoLowLikelihood: 272 | # Low likelihood match name 273 | - name: ActiveModel::Serializer 274 | # Flag to capture only capitalized names 275 | # e.g. would match `ApplicationController`, not `with_comments` 276 | classOrModule: true 277 | 278 | # Matcher for `.*Serializer$` 279 | # e.g. `UserSerializer`, `ProjectSerializer` 280 | termEndsWith: Serializer 281 | 282 | # Matcher for `^with_.*` 283 | # e.g. `with_comments`, `with_previous_payments` 284 | termStartsWith: with_ 285 | 286 | # Matcher for `^ApplicationController$` 287 | termEquals: ApplicationController 288 | 289 | # Matcher for `.*_factory.ex` 290 | # e.g. `lib/appname/user_factory.ex`, `lib/appname/project_factory.ex` 291 | pathEndsWith: _factory.ex 292 | 293 | # Matcher for `^app/policies.*` 294 | # e.g. `app/policies/user_policy.rb`, `app/policies/project_policy.rb` 295 | pathStartsWith: app/policies 296 | 297 | # list of termEquals 298 | # Matcher allowing any exact match from a list 299 | allowedTerms: 300 | - index? 301 | - edit? 302 | - create? 303 | ``` 304 | 305 | ### `~/.unused.yml` 306 | 307 | The first location is `~/.unused.yml`. This should hold widely-used 308 | configuration roughly applicable across projects. Here's an example of what 309 | might be present: 310 | 311 | ```yaml 312 | - name: Rails 313 | autoLowLikelihood: 314 | - name: ActiveModel::Serializer 315 | termEndsWith: Serializer 316 | classOrModule: true 317 | - name: Pundit 318 | termEndsWith: Policy 319 | classOrModule: true 320 | pathEndsWith: .rb 321 | - name: Pundit Helpers 322 | allowedTerms: 323 | - Scope 324 | - index? 325 | - new? 326 | - create? 327 | - show? 328 | - edit? 329 | - destroy? 330 | - resolve 331 | - name: JSONAPI::Resources 332 | termEndsWith: Resource 333 | classOrModule: true 334 | pathStartsWith: app/resources 335 | - name: JSONAPI::Resources Helpers 336 | allowedTerms: 337 | - updatable_fields 338 | pathStartsWith: app/resources 339 | ``` 340 | 341 | I tend to work on different APIs, and the two libraries I most commonly use 342 | have a fairly similar pattern when it comes to class naming. They both also 343 | use that naming structure to identify serializers automatically, meaning they 344 | very well may only be referenced once in the entire application (when they're 345 | initially defined). 346 | 347 | Similarly, with Pundit, an authorization library, naming conventions often 348 | mean only one reference to the class name. 349 | 350 | This is a file that might grow, but is focused on widely-used patterns across 351 | codebases. You might even want to check it into your dotfiles. 352 | 353 | ### `APP_ROOT/.unused.yml` 354 | 355 | The second location is `APP_ROOT/.unused.yml`. This is where any 356 | project-specific settings might live. If you're working on a library before 357 | extracting to a gem or package, you might have this configuration take that 358 | into account. 359 | 360 | ### Validation 361 | 362 | `unused` will attempt to parse both of these files, if it finds them. If 363 | either is invalid either due to missing or mistyped keys, an error will be 364 | displayed. 365 | 366 | ## Requirements 367 | 368 | Unused leverages [Ag](https://github.com/ggreer/the_silver_searcher) to 369 | analyze the codebase; as such, you'll need to have `ag` available in your 370 | `$PATH`. This is set as an explicit dependency in Homebrew. 371 | 372 | Alternatively, if you'd like to use 373 | [RipGrep](https://github.com/BurntSushi/ripgrep), you can do so with the 374 | `--search rg` flag. Be sure to have RipGrep installed first. 375 | 376 | ## Testing 377 | 378 | To run the test suite, run: 379 | 380 | ```sh 381 | stack test 382 | ``` 383 | 384 | ## License 385 | 386 | Copyright 2016-2018 Josh Clayton. See the [LICENSE](LICENSE). 387 | --------------------------------------------------------------------------------