Allow developer-authored configurations

This enables per-user and per-project configs, located in:

* ~/.unused.yml
* APP_ROOT/.unused.yml

Configurations stack upon each other, not replace; unused provides a
very base config, but additional configurations can be defined.

Per-user configs are best used to suit common types of projects at a
generic level. For example, a developer commonly working in Rails
applications might have a config at ~/.unused.yml for patterns like
Policy objects from Pundit, ActiveModel::Serializers, etc.

Per-project config would be less-generic patterns, ones where re-use
isn't likely or applicable.

See unused's global config:
https://github.com/joshuaclayton/unused/blob/master/data/config.yml

The structure is as follows:

    - name: Rails
      autoLowLikelihood:
        - name: Pundit
          pathStartsWith: app/policies
          pathEndsWith: .rb
          termEndsWith: Policy
          classOrModule: true
        - name: Pundit Helpers
          pathStartsWith: app/policies
          allowedTerms:
            - Scope
            - index?
            - new?
            - create?
            - show?
            - edit?
            - destroy?
            - resolve
    - name: Other Language
      autoLowLikelihood:
        - name: Thing
          pathEndsWith: .ex
          classOrModule: true

Name each item, and include an autoLowLikelihood key with multiple named
matchers. Each matcher can look for various formatting aspects,
including termStartsWith, termEndsWith, pathStartsWith, pathEndsWith,
classOrModule, and allowedTerms.
This commit is contained in:
Joshua Clayton 2016-06-05 07:40:47 -04:00
parent 5590f5fc4c
commit 58e219eb2e
7 changed files with 213 additions and 20 deletions

111
README.md
View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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))

View File

@ -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