{-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE PartialTypeSignatures #-} -- sort keys only care about Ord {-# OPTIONS_GHC -Wno-partial-type-signatures #-} -- | Parse the config file. module FixImports.Config where import Control.Monad (foldM, unless) import Data.Bifunctor (second) import qualified Data.Char as Char import qualified Data.Either as Either import qualified Data.List as List import qualified Data.Map as Map import qualified Data.Text as Text import Data.Text (Text) import qualified Data.Text.IO as Text.IO import qualified Data.Tuple as Tuple import qualified System.FilePath as FilePath import qualified System.IO as IO import qualified Text.Read as Read import qualified FixImports.Index as Index import qualified FixImports.Types as Types import qualified FixImports.Util as Util data Config = Config { -- | Additional directories to search for local modules. Taken from the -- -i flag and 'include' config line. _includes :: [FilePath] -- | These language extensions are enabled by default. , _language :: [Types.Extension] -- | Import sort order. Used by 'formatGroups'. , _order :: Order -- | Heuristics to pick the right module. Used by 'pickModule'. , _modulePriority :: Priorities -- | Map unqualified names to the module to import for them. , _unqualified :: Map.Map Types.Name Types.ModuleName -- | Map abbreviation to the complete qualification: -- > import-as: Data.Text.Lazy as DTL -> [("DTL", "Data.Text.Lazy")] , _qualifyAs :: Map.Map Types.Qualification Types.Qualification , _format :: Format , _debug :: Bool } deriving (Eq, Show) data Order = Order { _importOrder :: Priority ModulePattern -- | Put unqualified import-all imports last. , _sortUnqualifiedLast :: Bool } deriving (Eq, Show) data Priorities = Priorities { -- | Place these packages either first or last in priority. prioPackage :: Priority Index.Package -- | Place these modules either first or last in priority. , prioModule :: Priority Types.ModuleName } deriving (Eq, Show) instance Semigroup Priorities where Priorities a1 b1 <> Priorities a2 b2 = Priorities (a1<>a2) (b1<>b2) instance Monoid Priorities where mempty = Priorities mempty mempty data Priority a = Priority { high :: [a], low :: [a] } deriving (Eq, Show) instance Semigroup (Priority a) where Priority a1 b1 <> Priority a2 b2 = Priority (a1<>a2) (b1<>b2) instance Monoid (Priority a) where mempty = Priority mempty mempty data Format = Format { -- | If true, group imports by their first component. _groupImports :: Bool -- | Insert space for unqualified imports to make the modules line up. , _leaveSpaceForQualified :: Bool -- | Number of columns to wrap to. , _columns :: Int -- | How many spaces to indent a wrapped line. , _wrapIndent :: Int } deriving (Eq, Show) -- | A simple pattern: @M.@ matches M and M.*. Anything else matches exactly. type ModulePattern = String matchModule :: ModulePattern -> Types.ModuleName -> Bool matchModule pattern (Types.ModuleName mod) = case Util.unsnoc pattern of Nothing -> False Just (parent, '.') -> parent == mod || pattern `List.isPrefixOf` mod _ -> pattern == mod empty :: Config empty = Config { _includes = [] , _language = [] , _order = Order { _importOrder = Priority { high = [], low = [] } , _sortUnqualifiedLast = False } , _modulePriority = mempty , _unqualified = mempty , _qualifyAs = mempty , _format = defaultFormat , _debug = False } defaultFormat :: Format defaultFormat = Format { _groupImports = True , _leaveSpaceForQualified = False , _columns = 80 , _wrapIndent = 4 } -- | Parse .fix-imports file. parse :: Text -> (Config, [Text]) parse text = (config, errors) where commas = Text.intercalate ", " errors = map (".fix-imports: "<>) $ concat [ [ "duplicate fields: " <> commas duplicates | not (null duplicates) ] , [ "unrecognized fields: " <> commas unknownFields | not (null unknownFields) ] , [ "unknown language extensions: " <> commas unknownLanguage | not (null unknownLanguage) ] , [ "unqualified: " <> commas unknownUnqualified | not (null unknownUnqualified) ] , [ "qualify-as: " <> commas unknownQualifyAs | not (null unknownQualifyAs) ] , maybe [] ((:[]) . ("format: "<>)) formatError ] config = empty { _includes = getStrings "include" , _language = language , _order = Order { _importOrder = Priority { high = getStrings "import-order-first" , low = getStrings "import-order-last" } , _sortUnqualifiedLast = getBool "sort-unqualified-last" } , _modulePriority = Priorities { prioPackage = Priority { high = getStrings "prio-package-high" , low = getStrings "prio-package-low" } , prioModule = Priority { high = getModules "prio-module-high" , low = getModules "prio-module-low" } } , _unqualified = Map.fromList $ map Tuple.swap unqualified , _format = format , _qualifyAs = qualifyAs } (unknownUnqualified, unqualified) = Either.partitionEithers $ parseUnqualified (Text.unwords (get "unqualified")) (unknownLanguage, language) = parseLanguage (get "language") (unknownQualifyAs, qualifyAs) = parseQualifyAs $ Text.unwords $ get "qualify-as" (format, formatError) = case parseFormat (get "format") of Right format -> (format, Nothing) Left err -> (defaultFormat, Just err) unknownFields = Map.keys fields List.\\ valid valid = [ "format" , "import-order-first", "import-order-last" , "include" , "language" , "prio-module-high", "prio-module-low" , "prio-package-high", "prio-package-low" , "qualify-as" , "sort-unqualified-last" , "unqualified" ] fields = fmap (concatMap Text.words) $ Map.fromList sections sections = Index.parseSections text duplicates = map head $ filter ((>1) . length) $ List.group $ List.sort $ map fst sections getModules = map (Types.ModuleName . Text.unpack) . get get k = Map.findWithDefault [] k fields getStrings = map Text.unpack . get getBool k = k `Map.member` fields parseFormat :: [Text] -> Either Text Format parseFormat = foldM set defaultFormat where set fmt "leave-space-for-qualified" = Right $ fmt { _leaveSpaceForQualified = True } set fmt "no-group" = Right $ fmt { _groupImports = False } set fmt w | Just cols <- Text.stripPrefix "columns=" w = case Read.readMaybe (Text.unpack cols) of Nothing -> Left $ "non-numeric: " <> w Just cols -> Right $ fmt { _columns = cols } set _ w = Left $ "unrecognized word: " <> showt w -- | -- "A.B(c); Q(r)" -> [Right ("A.B", "c"), Right ("Q", "r")] parseUnqualified :: Text -> [Either Text (Types.ModuleName, Types.Name)] parseUnqualified = concatMap (parse . Text.break (=='(')) . Text.splitOn ";" where parse (pre, post) | Text.null pre && Text.null post = [] | Text.null pre = [Left $ "no module name before " <> showt post] | Text.null post = [Left $ "no import after " <> showt pre] | Just imports <- hasParens post = map (parseUnqualifiedImport (Text.strip pre)) (map Text.strip (Text.splitOn "," imports)) | otherwise = [Left $ "expected parens: " <> showt post] -- | -- "A.B" "(c)" -> (Types.Name "c", "A.B") -- "A.B" "((+))" -> (Types.Operator "+", "A.B") parseUnqualifiedImport :: Text -> Text -> Either Text (Types.ModuleName, Types.Name) parseUnqualifiedImport pre post = do unless (Text.all isModuleChar pre) $ Left $ "this doesn't look like a module name: " <> showt pre let module_ = Types.ModuleName (Text.unpack pre) case hasParens post of Just op | Text.all Util.haskellOpChar op -> Right (module_, Types.Operator (Text.unpack op)) | otherwise -> Left $ "non-symbols in operator: " <> showt post Nothing | Text.all (not . Util.haskellOpChar) post -> Right (module_, Types.Name (Text.unpack post)) | otherwise -> Left $ "symbol char in id, use parens: " <> showt post where isModuleChar c = Char.isLetter c || Char.isDigit c || c == '.' -- | -- "A.B as AB; C as E" -> [("AB", "A.B"), ("E", "C")] parseQualifyAs :: Text -> ([Text], Map.Map Types.Qualification Types.Qualification) parseQualifyAs field | Text.null field = ([], mempty) | otherwise = second Map.fromList . Either.partitionEithers . map (parse . Text.words) . Text.splitOn ";" $ field where parse [module_, "as", alias] = Right ( Types.Qualification (Text.unpack alias) , Types.Qualification (Text.unpack module_) ) parse ws = Left $ "stanza should look like 'ModuleName as X':" <> Text.unwords ws hasParens :: Text -> Maybe Text hasParens s | "(" `Text.isPrefixOf` s && ")" `Text.isSuffixOf` s = Just $ Text.drop 1 $ Text.dropEnd 1 s | otherwise = Nothing parseLanguage :: [Text] -> ([Text], [Types.Extension]) parseLanguage = Either.partitionEithers . map parse where parse w = maybe (Left w) Right $ Types.parseExtension $ Text.unpack w -- * pick candidates pickModule :: Priorities -> FilePath -> [(Maybe Index.Package, Types.ModuleName)] -> Maybe (Maybe Index.Package, Types.ModuleName) pickModule prios modulePath candidates = Util.minimumOn (uncurry (prioritize prios modulePath)) $ -- Don't pick myself! filter ((/= Types.pathToModule modulePath) . snd) candidates -- | The order of priority is: -- -- - high or low in 'prioModule' -- - local modules that share prefix with the module path -- - local modules to ones from packages -- - package modules high or low in 'prioPackage' -- - prefer with fewer dots, so System.IO over Data.Text.IO -- - If all else is equal alphabetize so at least the order is predictable. prioritize :: Priorities -> FilePath -> Maybe String -> Types.ModuleName -> _ prioritize prios modulePath mbPackage mod = ( modulePrio (prioModule prios) mod , localPrio mbPackage , packagePrio (prioPackage prios) mbPackage , length $ filter (=='.') $ Types.moduleName mod , Types.moduleName mod ) where localPrio Nothing = Before $ localOrder modulePath mod localPrio (Just _) = After packagePrio _ Nothing = Nothing packagePrio (Priority {high, low}) (Just pkg) = Just $ searchPrio high low pkg modulePrio (Priority {high, low}) = searchPrio (map Types.moduleName high) (map Types.moduleName low) . Types.moduleName -- | This is like Maybe, except that a present value will always sort before an -- absent one. data Before a = Before a | After deriving (Eq, Show) instance Ord a => Ord (Before a) where compare After After = EQ compare (Before _) After = LT compare After (Before _) = GT compare (Before a) (Before b) = compare a b -- | Lower numbers for modules that share more prefix with the module's path. -- A/B/Z.hs vs A.B.C -> -2 -- A/Z.hs vs B -> 0 localOrder :: FilePath -> Types.ModuleName -> Int localOrder modulePath mod = negate $ length $ takeWhile id $ zipWith (==) (Util.split "/" (Types.moduleToPath mod)) (Util.split "/" (FilePath.takeDirectory modulePath)) searchPrio :: [String] -> [String] -> String -> Int searchPrio high low mod = case List.findIndex (== mod) high of Just n -> - length high + n Nothing -> maybe 0 (+1) (List.findIndex (== mod) low) -- * log debug :: Config -> Text -> IO () debug config msg | _debug config = Text.IO.hPutStrLn IO.stderr msg | otherwise = return () showt :: Show a => a -> Text showt = Text.pack . show