{-# LANGUAGE ApplicativeDo #-} {-# LANGUAGE GADTs #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RecordWildCards #-} module OptEnvConf.Lint ( LintError (..), LintErrorMessage (..), renderLintErrors, renderLintError, lintParser, ) where import Control.Monad import Control.Monad.Reader import Data.Either import Data.Foldable import Data.List.NonEmpty (NonEmpty (..)) import qualified Data.List.NonEmpty as NE import Data.Maybe import Data.Text (Text) import qualified Data.Text as T import GHC.Stack (SrcLoc, prettySrcLoc) import OptEnvConf.Args import OptEnvConf.Parser import qualified OptEnvConf.Reader as OptEnvConf import OptEnvConf.Setting import OptEnvConf.Validation import Text.Colour data LintError = LintError { lintErrorSrcLoc :: !(Maybe SrcLoc), lintErrorMessage :: !LintErrorMessage } data LintErrorMessage = LintErrorUndocumented | LintErrorEmptySetting | LintErrorDashInShort | LintErrorDashInLong !(NonEmpty Char) | LintErrorNoReaderForArgument | LintErrorNoMetavarForArgument | LintErrorNoReaderForOption | LintErrorNoDashedForOption | LintErrorNoMetavarForOption | LintErrorNoDashedForSwitch | LintErrorNoOptionOrSwitchForDashed | LintErrorNoReaderForEnvVar | LintErrorNoMetavarForEnvVar | LintErrorNoCommands | LintErrorUnreadableExample !String | LintErrorConfigWithoutLoad | LintErrorManyInfinite renderLintErrors :: NonEmpty LintError -> [Chunk] renderLintErrors = unlinesChunks . ([fore red "Setting parser is invalid:"] :) . map (" " :) . concatMap (([] :) . renderLintError) renderLintError :: LintError -> [[Chunk]] renderLintError LintError {..} = concat [ [[fore red "Invalid Setting:"]], case lintErrorMessage of LintErrorUndocumented -> [["missing ", functionChunk "help", "."]] LintErrorEmptySetting -> concat [ [ [ "This ", functionChunk "setting", " parses nothing." ] ], [ [ "Add an ", functionChunk "argument", ", ", functionChunk "switch", ", ", functionChunk "option", ", ", functionChunk "env", ", ", functionChunk "conf", ", or ", functionChunk "value", "." ] ] ] LintErrorDashInShort -> [ [functionChunk "short", " may not contain a '-'."], ["Found ", functionChunk "short", " '-'."] ] LintErrorDashInLong s -> [ [functionChunk "long", " may not start with a '-'."], ["Found ", functionChunk "long", " ", chunk $ T.pack $ show $ NE.toList s, "."], [ "Try ", functionChunk "long", " ", chunk $ T.pack $ show $ let go = \case [] -> [] '-' : cs -> go cs c : cs -> c : cs in go $ NE.toList s, " instead." ] ] LintErrorNoReaderForArgument -> [ [ functionChunk "argument", " has no ", functionChunk "reader", "." ] ] LintErrorNoMetavarForArgument -> [ [ functionChunk "argument", " has no ", functionChunk "metavar", "." ] ] LintErrorNoReaderForOption -> [ [ functionChunk "option", " or ", functionChunk "name", " has no ", functionChunk "reader", "." ] ] LintErrorNoDashedForOption -> [ [ functionChunk "option", " has no ", functionChunk "long", " or ", functionChunk "short", "." ] ] LintErrorNoMetavarForOption -> [ [ functionChunk "option", " or ", functionChunk "name", " has no ", functionChunk "metavar", "." ] ] LintErrorNoDashedForSwitch -> [ [ functionChunk "switch", " has no ", functionChunk "long", " or ", functionChunk "short", "." ] ] LintErrorNoOptionOrSwitchForDashed -> [ [ functionChunk "long", " or ", functionChunk "short", " has no ", functionChunk "option", " or ", functionChunk "switch", "." ] ] LintErrorNoReaderForEnvVar -> [ [ functionChunk "env", " or ", functionChunk "name", " has no ", functionChunk "reader", "." ] ] LintErrorNoMetavarForEnvVar -> [ [ functionChunk "env", " or ", functionChunk "name", " has no ", functionChunk "metavar", "." ] ] LintErrorNoCommands -> [ [ functionChunk "commands", " was called with an empty list." ] ] LintErrorUnreadableExample e -> [ [functionChunk "example", " was called with an example that none of the ", functionChunk "reader", "s succeed in reading."], ["Example: ", chunk $ T.pack e] ] LintErrorConfigWithoutLoad -> [ [ functionChunk "conf", " or ", functionChunk "name", " was called with no way to load configuration." ], [ "You can load configuration with ", functionChunk "withConfig", ", or explicitly not load any configuration with ", functionChunk "withoutConfig", "." ] ] LintErrorManyInfinite -> [ [ functionChunk "many", " or ", functionChunk "some", " was called with a parser that may succeed without consuming anything." ], ["This is not allowed because the parser would run infinitely."] ], maybe [] (pure . ("Defined at: " :) . pure . fore cyan . chunk . T.pack . prettySrcLoc) lintErrorSrcLoc ] functionChunk :: Text -> Chunk functionChunk = fore yellow . chunk lintParser :: Parser a -> Maybe (NonEmpty LintError) lintParser = either Just (const Nothing) . validationToEither . (`runReader` False) -- Set to true for parsers that have a way to load conf . runValidationT . go where -- Returns whether 'many' is allowed. -- 'many' is allowed only when every parse below consumes something. go :: Parser a -> ValidationT LintError (Reader Bool) Bool go = \case ParserPure _ -> pure False ParserAp p1 p2 -> do c1 <- go p1 c2 <- go p2 pure (c1 || c2) ParserSelect p1 p2 -> do c1 <- go p1 c2 <- go p2 pure (c1 || c2) -- TODO: is this right? ParserEmpty _ -> pure True ParserAlt p1 p2 -> do c1 <- go p1 c2 <- go p2 pure (c1 && c2) -- TODO: is this right? -- TODO lint if we don't try to parse anything consuming under many. ParserMany p -> do c <- go p when (not c) $ mapValidationTFailure (LintError Nothing) $ validationTFailure LintErrorManyInfinite pure c ParserSome p -> do c <- go p when (not c) $ mapValidationTFailure (LintError Nothing) $ validationTFailure LintErrorManyInfinite pure c ParserAllOrNothing _ p -> go p ParserCheck _ _ _ p -> go p ParserCommands mLoc _ ls -> do if null ls then validationTFailure $ LintError mLoc LintErrorNoCommands else and <$> traverse (go . commandParser) ls -- TODO is this right? ParserWithConfig _ p1 p2 -> do c1 <- go p1 c2 <- local (const True) (go p2) pure $ c1 || c2 ParserSetting mLoc Setting {..} -> mapValidationTFailure (LintError mLoc) $ do case settingHelp of Nothing -> -- Hidden values may be undocumented when (not settingHidden) $ validationTFailure LintErrorUndocumented Just _ -> pure () when ( and [ not settingTryArgument, isNothing settingSwitchValue, not settingTryOption, isNothing settingEnvVars, isNothing settingConfigVals, isNothing settingDefaultValue ] ) $ validationTFailure LintErrorEmptySetting for_ settingDasheds $ \case DashedLong cs@('-' :| _) -> validationTFailure $ LintErrorDashInLong cs DashedShort '-' -> validationTFailure LintErrorDashInShort _ -> pure () when (settingTryArgument && null settingReaders) $ validationTFailure LintErrorNoReaderForArgument when (settingTryArgument && not settingHidden && isNothing settingMetavar) $ validationTFailure LintErrorNoMetavarForArgument when (settingTryOption && null settingReaders) $ validationTFailure LintErrorNoReaderForOption when (settingTryOption && null settingDasheds) $ validationTFailure LintErrorNoDashedForOption when (settingTryOption && not settingHidden && isNothing settingMetavar) $ validationTFailure LintErrorNoMetavarForOption when (isJust settingSwitchValue && null settingDasheds) $ validationTFailure LintErrorNoDashedForSwitch when (not settingTryOption && isNothing settingSwitchValue && not (null settingDasheds)) $ validationTFailure LintErrorNoOptionOrSwitchForDashed when (isJust settingEnvVars && null settingReaders) $ validationTFailure LintErrorNoReaderForEnvVar when (isJust settingEnvVars && not settingHidden && isNothing settingMetavar) $ validationTFailure LintErrorNoMetavarForEnvVar for_ settingExamples $ \e -> let canRead r = isRight $ OptEnvConf.runReader r e in when ((settingTryArgument || settingTryOption) && not (any canRead settingReaders)) $ validationTFailure $ LintErrorUnreadableExample e hasConfig <- ask when (isJust settingConfigVals && not hasConfig) $ validationTFailure LintErrorConfigWithoutLoad pure $ -- 'many' is only allowed if something is being consumed and it's -- impossible for nothing to be consumed. and [ settingTryArgument || settingTryOption || isJust settingSwitchValue, null settingEnvVars, null settingConfigVals ]