mirror of
https://github.com/joshuaclayton/unused.git
synced 2024-10-26 05:07:35 +03:00
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:
parent
5590f5fc4c
commit
58e219eb2e
111
README.md
111
README.md
@ -87,6 +87,117 @@ To view more usage options, run:
|
|||||||
unused --help
|
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
|
## Requirements
|
||||||
|
|
||||||
Unused leverages [Ag](https://github.com/ggreer/the_silver_searcher) to
|
Unused leverages [Ag](https://github.com/ggreer/the_silver_searcher) to
|
||||||
|
38
app/Main.hs
38
app/Main.hs
@ -1,5 +1,8 @@
|
|||||||
module Main where
|
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 Options.Applicative
|
||||||
import Data.Maybe (fromMaybe)
|
import Data.Maybe (fromMaybe)
|
||||||
import Unused.Parser (parseResults)
|
import Unused.Parser (parseResults)
|
||||||
@ -26,33 +29,35 @@ data Options = Options
|
|||||||
}
|
}
|
||||||
|
|
||||||
main :: IO ()
|
main :: IO ()
|
||||||
main =
|
main = runProgram =<< execParser (withInfo parseOptions pHeader pDescription pFooter)
|
||||||
run =<< execParser
|
|
||||||
(withInfo parseOptions pHeader pDescription pFooter)
|
|
||||||
where
|
where
|
||||||
|
runProgram options = withRuntime $
|
||||||
|
runExceptT (run options) >>= either renderError return
|
||||||
pHeader = "Unused: Analyze potentially unused code"
|
pHeader = "Unused: Analyze potentially unused code"
|
||||||
pDescription = "Unused allows a developer to leverage an existing tags file\
|
pDescription = "Unused allows a developer to leverage an existing tags file\
|
||||||
\ (located at .git/tags, tags, or tmp/tags) to identify tokens\
|
\ (located at .git/tags, tags, or tmp/tags) to identify tokens\
|
||||||
\ in a codebase that are unused."
|
\ in a codebase that are unused."
|
||||||
pFooter = "CLI USAGE: $ unused"
|
pFooter = "CLI USAGE: $ unused"
|
||||||
|
|
||||||
run :: Options -> IO ()
|
data LocalizedError = TagError TagSearchOutcome | InvalidConfigError [ParseConfigError]
|
||||||
run options = withRuntime $ do
|
|
||||||
terms' <- calculateTagInput options
|
|
||||||
|
|
||||||
case terms' of
|
renderError :: LocalizedError -> IO ()
|
||||||
(Left e) -> V.missingTagsFileError e
|
renderError (TagError e) = V.missingTagsFileError e
|
||||||
(Right terms'') -> do
|
renderError (InvalidConfigError e) = V.invalidConfigError e
|
||||||
languageConfig <- loadLanguageConfig
|
|
||||||
|
|
||||||
let terms = termsWithAlternatesFromConfig languageConfig terms''
|
run :: Options -> ExceptT LocalizedError IO ()
|
||||||
|
run options = do
|
||||||
|
terms' <- withException TagError $ calculateTagInput options
|
||||||
|
languageConfig <- withException InvalidConfigError loadAllConfigurations
|
||||||
|
|
||||||
renderHeader terms
|
let terms = termsWithAlternatesFromConfig languageConfig terms'
|
||||||
results <- withCache options $ executeSearch (oSearchRunner options) 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 :: [LanguageConfiguration] -> [String] -> [String]
|
||||||
termsWithAlternatesFromConfig lcs =
|
termsWithAlternatesFromConfig lcs =
|
||||||
@ -63,9 +68,6 @@ termsWithAlternatesFromConfig lcs =
|
|||||||
printResults :: Options -> TermMatchSet -> IO ()
|
printResults :: Options -> TermMatchSet -> IO ()
|
||||||
printResults options = V.searchResults . groupedResponses (oGrouping options) . optionFilters options
|
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 -> IO (Either TagSearchOutcome [String])
|
||||||
calculateTagInput Options{ oFromStdIn = True } = loadTagsFromPipe
|
calculateTagInput Options{ oFromStdIn = True } = loadTagsFromPipe
|
||||||
calculateTagInput Options{ oFromStdIn = False } = loadTagsFromFile
|
calculateTagInput Options{ oFromStdIn = False } = loadTagsFromFile
|
||||||
|
@ -5,4 +5,5 @@ module Unused.CLI.Views
|
|||||||
import Unused.CLI.Views.NoResultsFound as X
|
import Unused.CLI.Views.NoResultsFound as X
|
||||||
import Unused.CLI.Views.AnalysisHeader as X
|
import Unused.CLI.Views.AnalysisHeader as X
|
||||||
import Unused.CLI.Views.MissingTagsFileError as X
|
import Unused.CLI.Views.MissingTagsFileError as X
|
||||||
|
import Unused.CLI.Views.InvalidConfigError as X
|
||||||
import Unused.CLI.Views.SearchResult as X
|
import Unused.CLI.Views.SearchResult as X
|
||||||
|
27
src/Unused/CLI/Views/InvalidConfigError.hs
Normal file
27
src/Unused/CLI/Views/InvalidConfigError.hs
Normal 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
|
@ -1,15 +1,45 @@
|
|||||||
module Unused.ResultsClassifier.Config
|
module Unused.ResultsClassifier.Config
|
||||||
( loadConfig
|
( loadConfig
|
||||||
|
, loadAllConfigurations
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import qualified Data.Yaml as Y
|
import qualified Data.Yaml as Y
|
||||||
import qualified Data.ByteString as BS
|
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.FilePath ((</>))
|
||||||
|
import System.Directory (getHomeDirectory)
|
||||||
import Paths_unused (getDataFileName)
|
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 :: IO (Either String [LanguageConfiguration])
|
||||||
loadConfig = Y.decodeEither <$> readConfig
|
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 :: IO BS.ByteString
|
||||||
readConfig = getDataFileName ("data" </> "config.yml") >>= BS.readFile
|
readConfig = getDataFileName ("data" </> "config.yml") >>= BS.readFile
|
||||||
|
@ -7,6 +7,7 @@ module Unused.ResultsClassifier.Types
|
|||||||
, TermAlias(..)
|
, TermAlias(..)
|
||||||
, Position(..)
|
, Position(..)
|
||||||
, Matcher(..)
|
, Matcher(..)
|
||||||
|
, ParseConfigError(..)
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import Control.Monad (mzero)
|
import Control.Monad (mzero)
|
||||||
@ -35,6 +36,11 @@ data TermAlias = TermAlias
|
|||||||
, taTo :: String
|
, taTo :: String
|
||||||
} deriving Show
|
} deriving Show
|
||||||
|
|
||||||
|
data ParseConfigError = ParseConfigError
|
||||||
|
{ pcePath :: String
|
||||||
|
, pceParseError :: String
|
||||||
|
}
|
||||||
|
|
||||||
data Position = StartsWith | EndsWith | Equals deriving Show
|
data Position = StartsWith | EndsWith | Equals deriving Show
|
||||||
data Matcher = Term Position String | Path Position String | AppOccurrences Int | AllowedTerms [String] 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 "allowedTerms" = Right AllowedTerms
|
||||||
keyToMatcher t = Left t
|
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 :: Y.Object -> Y.Parser [Matcher]
|
||||||
parseMatchers o =
|
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
|
where
|
||||||
myFold :: (Foldable t, Monad m) => (a -> a -> a) -> t (m a) -> m a
|
myFold :: (Foldable t, Monad m) => (a -> a -> a) -> t (m a) -> m a
|
||||||
myFold f = foldl1 (\acc i -> acc >>= (\l -> f l <$> i))
|
myFold f = foldl1 (\acc i -> acc >>= (\l -> f l <$> i))
|
||||||
|
@ -43,6 +43,7 @@ library
|
|||||||
, Unused.CLI.Views.NoResultsFound
|
, Unused.CLI.Views.NoResultsFound
|
||||||
, Unused.CLI.Views.AnalysisHeader
|
, Unused.CLI.Views.AnalysisHeader
|
||||||
, Unused.CLI.Views.MissingTagsFileError
|
, Unused.CLI.Views.MissingTagsFileError
|
||||||
|
, Unused.CLI.Views.InvalidConfigError
|
||||||
, Unused.CLI.Views.SearchResult
|
, Unused.CLI.Views.SearchResult
|
||||||
, Unused.CLI.Views.SearchResult.ColumnFormatter
|
, Unused.CLI.Views.SearchResult.ColumnFormatter
|
||||||
, Unused.CLI.ProgressIndicator
|
, Unused.CLI.ProgressIndicator
|
||||||
@ -77,6 +78,8 @@ executable unused
|
|||||||
build-depends: base
|
build-depends: base
|
||||||
, unused
|
, unused
|
||||||
, optparse-applicative
|
, optparse-applicative
|
||||||
|
, mtl
|
||||||
|
, transformers
|
||||||
default-language: Haskell2010
|
default-language: Haskell2010
|
||||||
|
|
||||||
test-suite unused-test
|
test-suite unused-test
|
||||||
|
Loading…
Reference in New Issue
Block a user