diff --git a/README.md b/README.md index 2553e23..c3dac5e 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,117 @@ To view more usage options, run: unused --help ``` +## Custom Configuration + +The first time you use `unused`, you might see a handful of false positives. +`unused` will look in two additional locations in an attempt to load +additional custom configuration to help improve this. + +### Configuration format + +```yaml +# Language or framework name +# e.g. Rails, Ruby, Go, Play +- name: Framework or language + # Collection of matches allowed to have one occurrence + autoLowLikelihood: + # Low likelihood match name + - name: ActiveModel::Serializer + # Flag to capture only capitalized names + # e.g. would match `ApplicationController`, not `with_comments` + classOrModule: true + + # Matcher for `.*Serializer$` + # e.g. `UserSerializer`, `ProjectSerializer` + termEndsWith: Serializer + + # Matcher for `^with_.*` + # e.g. `with_comments`, `with_previous_payments` + termStartsWith: with_ + + # Matcher for `^ApplicationController$` + termEquals: ApplicationController + + # Matcher for `.*_factory.ex` + # e.g. `lib/appname/user_factory.ex`, `lib/appname/project_factory.ex` + pathEndsWith: _factory.ex + + # Matcher for `^app/policies.*` + # e.g. `app/policies/user_policy.rb`, `app/policies/project_policy.rb` + pathStartsWith: app/policies + + # Matcher for `^config/application.rb$` + pathEquals: config/application.rb + + # list of termEquals + # Matcher allowing any exact match from a list + allowedTerms: + - index? + - edit? + - create? +``` + +### `~/.unused.yml` + +The first location is `~/.unused.yml`. This should hold widely-used +configuration roughly applicable across projects. Here's an example of what +might be present: + +```yaml +- name: Rails + autoLowLikelihood: + - name: ActiveModel::Serializer + termEndsWith: Serializer + classOrModule: true + - name: Pundit + termEndsWith: Policy + classOrModule: true + pathEndsWith: .rb + - name: Pundit Helpers + allowedTerms: + - Scope + - index? + - new? + - create? + - show? + - edit? + - destroy? + - resolve + - name: JSONAPI::Resources + termEndsWith: Resource + classOrModule: true + pathStartsWith: app/resources + - name: JSONAPI::Resources Helpers + allowedTerms: + - updatable_fields + pathStartsWith: app/resources +``` + +I tend to work on different APIs, and the two libraries I most commonly use +have a fairly similar pattern when it comes to class naming. They both also +use that naming structure to identify serializers automatically, meaning they +very well may only be referenced once in the entire application (when they're +initially defined). + +Similarly, with Pundit, an authorization library, naming conventions often +mean only one reference to the class name. + +This is a file that might grow, but is focused on widely-used patterns across +codebases. You might even want to check it into your dotfiles. + +### `APP_ROOT/.unused.yml` + +The second location is `APP_ROOT/.unused.yml`. This is where any +project-specific settings might live. If you're working on a library before +extracting to a gem or package, you might have this configuration take that +into account. + +### Validation + +`unused` will attempt to parse both of these files, if it finds them. If +either is invalid either due to missing or mistyped keys, an error will be +displayed. + ## Requirements Unused leverages [Ag](https://github.com/ggreer/the_silver_searcher) to diff --git a/app/Main.hs b/app/Main.hs index fe98064..9d3f03e 100644 --- a/app/Main.hs +++ b/app/Main.hs @@ -1,5 +1,8 @@ module Main where +import Control.Monad.IO.Class (liftIO) +import qualified Data.Bifunctor as B +import Control.Monad.Except (ExceptT(..), runExceptT) import Options.Applicative import Data.Maybe (fromMaybe) import Unused.Parser (parseResults) @@ -26,33 +29,35 @@ data Options = Options } main :: IO () -main = - run =<< execParser - (withInfo parseOptions pHeader pDescription pFooter) +main = runProgram =<< execParser (withInfo parseOptions pHeader pDescription pFooter) where + runProgram options = withRuntime $ + runExceptT (run options) >>= either renderError return pHeader = "Unused: Analyze potentially unused code" pDescription = "Unused allows a developer to leverage an existing tags file\ \ (located at .git/tags, tags, or tmp/tags) to identify tokens\ \ in a codebase that are unused." pFooter = "CLI USAGE: $ unused" -run :: Options -> IO () -run options = withRuntime $ do - terms' <- calculateTagInput options +data LocalizedError = TagError TagSearchOutcome | InvalidConfigError [ParseConfigError] - case terms' of - (Left e) -> V.missingTagsFileError e - (Right terms'') -> do - languageConfig <- loadLanguageConfig +renderError :: LocalizedError -> IO () +renderError (TagError e) = V.missingTagsFileError e +renderError (InvalidConfigError e) = V.invalidConfigError e - let terms = termsWithAlternatesFromConfig languageConfig terms'' +run :: Options -> ExceptT LocalizedError IO () +run options = do + terms' <- withException TagError $ calculateTagInput options + languageConfig <- withException InvalidConfigError loadAllConfigurations - renderHeader terms - results <- withCache options $ executeSearch (oSearchRunner options) terms + let terms = termsWithAlternatesFromConfig languageConfig terms' - printResults options $ parseResults languageConfig results + liftIO $ renderHeader terms + results <- liftIO $ withCache options $ executeSearch (oSearchRunner options) terms - return () + liftIO $ printResults options $ parseResults languageConfig results + where + withException e = ExceptT . fmap (B.first e) termsWithAlternatesFromConfig :: [LanguageConfiguration] -> [String] -> [String] termsWithAlternatesFromConfig lcs = @@ -63,9 +68,6 @@ termsWithAlternatesFromConfig lcs = printResults :: Options -> TermMatchSet -> IO () printResults options = V.searchResults . groupedResponses (oGrouping options) . optionFilters options -loadLanguageConfig :: IO [LanguageConfiguration] -loadLanguageConfig = either (const []) id <$> loadConfig - calculateTagInput :: Options -> IO (Either TagSearchOutcome [String]) calculateTagInput Options{ oFromStdIn = True } = loadTagsFromPipe calculateTagInput Options{ oFromStdIn = False } = loadTagsFromFile diff --git a/src/Unused/CLI/Views.hs b/src/Unused/CLI/Views.hs index e4e0166..b110d59 100644 --- a/src/Unused/CLI/Views.hs +++ b/src/Unused/CLI/Views.hs @@ -5,4 +5,5 @@ module Unused.CLI.Views import Unused.CLI.Views.NoResultsFound as X import Unused.CLI.Views.AnalysisHeader as X import Unused.CLI.Views.MissingTagsFileError as X +import Unused.CLI.Views.InvalidConfigError as X import Unused.CLI.Views.SearchResult as X diff --git a/src/Unused/CLI/Views/InvalidConfigError.hs b/src/Unused/CLI/Views/InvalidConfigError.hs new file mode 100644 index 0000000..8126e15 --- /dev/null +++ b/src/Unused/CLI/Views/InvalidConfigError.hs @@ -0,0 +1,27 @@ +module Unused.CLI.Views.InvalidConfigError + ( invalidConfigError + ) where + +import Unused.CLI.Util +import Unused.ResultsClassifier (ParseConfigError(..)) + +invalidConfigError :: [ParseConfigError] -> IO () +invalidConfigError es = do + setSGR [SetColor Background Vivid Red] + setSGR [SetColor Foreground Vivid White] + setSGR [SetConsoleIntensity BoldIntensity] + + putStrLn "\nThere was a problem with the following config file(s):\n" + + setSGR [Reset] + + mapM_ configError es + + setSGR [Reset] + +configError :: ParseConfigError -> IO () +configError ParseConfigError{ pcePath = path, pceParseError = msg} = do + setSGR [SetConsoleIntensity BoldIntensity] + putStrLn path + setSGR [Reset] + putStrLn $ " " ++ msg diff --git a/src/Unused/ResultsClassifier/Config.hs b/src/Unused/ResultsClassifier/Config.hs index 68f61d9..ea09831 100644 --- a/src/Unused/ResultsClassifier/Config.hs +++ b/src/Unused/ResultsClassifier/Config.hs @@ -1,15 +1,45 @@ module Unused.ResultsClassifier.Config ( loadConfig + , loadAllConfigurations ) where import qualified Data.Yaml as Y import qualified Data.ByteString as BS +import qualified Data.ByteString.Char8 as C +import qualified Data.Either as E +import qualified Data.Bifunctor as B import System.FilePath (()) +import System.Directory (getHomeDirectory) import Paths_unused (getDataFileName) -import Unused.ResultsClassifier.Types (LanguageConfiguration) +import Unused.ResultsClassifier.Types (LanguageConfiguration, ParseConfigError(..)) +import Unused.Util (readIfFileExists) loadConfig :: IO (Either String [LanguageConfiguration]) loadConfig = Y.decodeEither <$> readConfig +loadAllConfigurations :: IO (Either [ParseConfigError] [LanguageConfiguration]) +loadAllConfigurations = do + homeDir <- getHomeDirectory + + defaultConfig <- addSourceToLeft "default config" <$> loadConfig + localConfig <- loadConfigFromFile ".unused.yml" + userConfig <- loadConfigFromFile $ homeDir ".unused.yml" + + let (lefts, rights) = E.partitionEithers [defaultConfig, localConfig, userConfig] + + if not (null lefts) + then return $ Left lefts + else return $ Right $ concat rights + +loadConfigFromFile :: String -> IO (Either ParseConfigError [LanguageConfiguration]) +loadConfigFromFile path = do + file <- fmap C.pack <$> readIfFileExists path + return $ case file of + Nothing -> Right [] + Just body -> addSourceToLeft path $ Y.decodeEither body + +addSourceToLeft :: String -> Either String c -> Either ParseConfigError c +addSourceToLeft source = B.first (ParseConfigError source) + readConfig :: IO BS.ByteString readConfig = getDataFileName ("data" "config.yml") >>= BS.readFile diff --git a/src/Unused/ResultsClassifier/Types.hs b/src/Unused/ResultsClassifier/Types.hs index cf673ef..35a03b7 100644 --- a/src/Unused/ResultsClassifier/Types.hs +++ b/src/Unused/ResultsClassifier/Types.hs @@ -7,6 +7,7 @@ module Unused.ResultsClassifier.Types , TermAlias(..) , Position(..) , Matcher(..) + , ParseConfigError(..) ) where import Control.Monad (mzero) @@ -35,6 +36,11 @@ data TermAlias = TermAlias , taTo :: String } deriving Show +data ParseConfigError = ParseConfigError + { pcePath :: String + , pceParseError :: String + } + data Position = StartsWith | EndsWith | Equals deriving Show data Matcher = Term Position String | Path Position String | AppOccurrences Int | AllowedTerms [String] deriving Show @@ -95,9 +101,22 @@ stringListHandler = MatchHandler keyToMatcher "allowedTerms" = Right AllowedTerms keyToMatcher t = Left t +lowLikelihoodMatchKeys :: [T.Text] +lowLikelihoodMatchKeys = + map T.pack $ ["name", "classOrModule"] ++ mhKeys intHandler ++ mhKeys stringHandler ++ mhKeys stringListHandler + +validateLowLikelihoodKeys :: Y.Object -> Y.Parser [Matcher] -> Y.Parser [Matcher] +validateLowLikelihoodKeys o ms = + if fullOverlap + then ms + else fail $ "The following keys are unsupported: " ++ L.intercalate ", " (T.unpack <$> unsupportedKeys) + where + fullOverlap = null unsupportedKeys + unsupportedKeys = keys o L.\\ lowLikelihoodMatchKeys + parseMatchers :: Y.Object -> Y.Parser [Matcher] parseMatchers o = - myFold (++) [buildMatcherList o intHandler, buildMatcherList o stringHandler, buildMatcherList o stringListHandler] + validateLowLikelihoodKeys o $ myFold (++) [buildMatcherList o intHandler, buildMatcherList o stringHandler, buildMatcherList o stringListHandler] where myFold :: (Foldable t, Monad m) => (a -> a -> a) -> t (m a) -> m a myFold f = foldl1 (\acc i -> acc >>= (\l -> f l <$> i)) diff --git a/unused.cabal b/unused.cabal index 93adb8a..935b697 100644 --- a/unused.cabal +++ b/unused.cabal @@ -43,6 +43,7 @@ library , Unused.CLI.Views.NoResultsFound , Unused.CLI.Views.AnalysisHeader , Unused.CLI.Views.MissingTagsFileError + , Unused.CLI.Views.InvalidConfigError , Unused.CLI.Views.SearchResult , Unused.CLI.Views.SearchResult.ColumnFormatter , Unused.CLI.ProgressIndicator @@ -77,6 +78,8 @@ executable unused build-depends: base , unused , optparse-applicative + , mtl + , transformers default-language: Haskell2010 test-suite unused-test