fix-imports 2.3.0 → 2.4.0
raw patch · 25 files changed
+2749/−2059 lines, 25 filesdep +Cabaldep +ghc-lib-parserdep +ghc-lib-parser-exdep −haskell-src-exts
Dependencies added: Cabal, ghc-lib-parser, ghc-lib-parser-ex, ghc-paths
Dependencies removed: haskell-src-exts
Files
- README.md +11/−14
- changelog.md +12/−0
- dot-fix-imports +2/−1
- fix-imports.cabal +39/−15
- src/Config.hs +0/−514
- src/Config_test.hs +0/−178
- src/FixImports.hs +0/−650
- src/FixImports/Config.hs +333/−0
- src/FixImports/Config_test.hs +87/−0
- src/FixImports/FixImports.hs +590/−0
- src/FixImports/FixImports_test.hs +266/−0
- src/FixImports/Format.hs +133/−0
- src/FixImports/Format_test.hs +114/−0
- src/FixImports/Index.hs +174/−0
- src/FixImports/Main.hs +143/−0
- src/FixImports/Parse.hs +281/−0
- src/FixImports/Parse_test.hs +157/−0
- src/FixImports/PkgCache.hs +53/−0
- src/FixImports/Types.hs +167/−0
- src/FixImports/Util.hs +187/−0
- src/FixImports_test.hs +0/−213
- src/Index.hs +0/−95
- src/Main.hs +0/−122
- src/Types.hs +0/−77
- src/Util.hs +0/−180
README.md view
@@ -24,21 +24,18 @@ The `-i` flag is like ghc's `-i` flag, it will add an aditional root to the module search path. The example will find modules in both `test/*` and-`src/*`, in addition to the global package db.+`src/*`, in addition to the package db. -About the global package db, `fix-imports` uses the `ghc-pkg` command to find-packages, so it will see whatever you see if you do `ghc-pkg list`. If it-doesn't see the right things for your package, say for the new nix-style-builds, you'll have to figure out how to fix that. As is usual for cabal and-ghc integration, ghc has several overlapping but documented configuration-methods, and cabal is completely undocumented. The relevant bits for ghc are-GHC_PACKAGE_PATH and perhaps package environments:-https://downloads.haskell.org/~ghc/latest/docs/html/users_guide/packages.html#the-ghc-package-path-environment-variable-Cabal doesn't seem to document how to get the appropriate package path for a-nix-style build. I don't use cabal so I haven't figured this out yet, but-let me know if you know or figure it out.+`fix-imports` will look for `.ghc.environment.*` in the current directory+and use it for pkgs to search. This is created by cabal v2, but only if+you have `write-ghc-environment-files: always` in `cabal.project`.+Otherwise, it assumes cabal v1 and will use the `ghc-pkg` command to use+the global package db. -I don't use stack either, but my understanding is this is enough to get-`ghc-pkg` working:+If it doesn't seem to see packages you think it should, run with `--debug`+to see what it sees.++I don't use stack, but my understanding is this is enough to get `ghc-pkg`+working: export GHC_PACKAGE_PATH=$(stack path --ghc-package-path)
changelog.md view
@@ -1,3 +1,15 @@+### 2.4.0++- Support cabal v2, look for .ghc.environment.* and get pkgs from there.++- Look for config in ~/.config/fix-imports in addition to ./.fix-imports.++- Switch from haskell-src-exts to ghc-lib-parser. This fixes a bunch of+parsing bugs. Along the way I fixed a bug where locally bound names were+misinterpreted as unqualified names.++- Detect and abort on CPP in the import block, instead of silently deleting it.+ ### 2.3.0 - add --edit flag, so I can just replace imports, instead of the whole file
dot-fix-imports view
@@ -1,5 +1,6 @@ -- fix-imports looks for a .fix-imports file in the current directory for--- per-project configuration. You can set the file explicitly with --config.+-- per-project configuration, otherwise in ~/.config/fix-imports.+-- You can set the file explicitly with --config. -- -- The syntax is like ghc-pkg or cabal: word, colon, and a list of words. -- A line with leading space is a continuation of the previous line.
fix-imports.cabal view
@@ -1,5 +1,5 @@ name: fix-imports-version: 2.3.0+version: 2.4.0 cabal-version: >= 1.10 build-type: Simple synopsis: Program to manage the imports of a haskell module@@ -38,28 +38,43 @@ location: git://github.com/elaforge/fix-imports.git executable fix-imports- main-is: Main.hs+ main-is: FixImports/Main.hs other-modules:- Config FixImports Index Types Util+ FixImports.Config+ FixImports.FixImports+ FixImports.Format+ FixImports.Index+ FixImports.Parse+ FixImports.PkgCache+ FixImports.Types+ FixImports.Util Paths_fix_imports hs-source-dirs: src default-language: Haskell2010 build-depends: base >= 3 && < 5+ , Cabal , containers , cpphs , deepseq , directory , filepath- , haskell-src-exts >= 1.16.0+ , ghc-lib-parser == 9.2.5.20221107+ , ghc-lib-parser-ex == 9.2.1.1+ , ghc-paths , mtl+ , pretty , process , split , text , time- , pretty , uniplate- ghc-options: -Wall -fno-warn-name-shadowing+ -- , test-karya+ -- , el-debug+ ghc-options:+ -main-is FixImports.Main+ -Wall+ -fno-warn-name-shadowing test-suite test type: exitcode-stdio-1.0@@ -67,27 +82,36 @@ main-is: RunTests.hs default-language: Haskell2010 build-depends:+ -- copy paste of executable, TODO is there a way to deduplicate? base >= 3 && < 5+ , Cabal , containers , cpphs , deepseq , directory , filepath- , haskell-src-exts >= 1.16.0+ , ghc-lib-parser == 9.2.5.20221107+ , ghc-lib-parser-ex == 9.2.1.1+ , ghc-paths , mtl+ , pretty , process , split , text , time- , pretty , uniplate , test-karya other-modules:- Config- Config_test- FixImports- FixImports_test- Index- Types- Util+ FixImports.Config+ FixImports.Config_test+ FixImports.FixImports+ FixImports.FixImports_test+ FixImports.Format+ FixImports.Format_test+ FixImports.Index+ FixImports.Parse+ FixImports.Parse_test+ FixImports.PkgCache+ FixImports.Types+ FixImports.Util
− src/Config.hs
@@ -1,514 +0,0 @@--- | Per-project 'Config' and functions to interpret it.-{-# LANGUAGE LambdaCase #-}-{-# LANGUAGE NamedFieldPuns #-}-{-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE PartialTypeSignatures #-} -- sort keys only care about Ord-{-# OPTIONS_GHC -Wno-partial-type-signatures #-}-module 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.Maybe as Maybe-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 Language.Haskell.Exts as Haskell-import qualified Language.Haskell.Exts.Extension as Extension-import qualified System.FilePath as FilePath-import qualified System.IO as IO-import qualified Text.PrettyPrint as PP-import Text.PrettyPrint ((<+>))-import qualified Text.Read as Read--import qualified Index-import qualified Types-import qualified 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 :: [Extension.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 (Haskell.Name ()) Types.ModuleName- -- import-as: Data.Text.Lazy as DTL -> Map 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- } 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- }---- | 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 = 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, Haskell.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)" -> (Haskell.Ident () "c", "A.B")--- "A.B" "((+))" -> (Haskell.Symbol () "+", "A.B")-parseUnqualifiedImport :: Text -> Text- -> Either Text (Types.ModuleName, Haskell.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_, Haskell.Symbol () (Text.unpack op))- | otherwise -> Left $ "non-symbols in operator: " <> showt post- Nothing- | Text.all (not . Util.haskellOpChar) post ->- Right (module_, Haskell.Ident () (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], [Extension.Extension])-parseLanguage = Either.partitionEithers . map parse- where- parse w = case Extension.parseExtension (Text.unpack w) of- Extension.UnknownExtension _ -> Left w- ext -> Right ext---- * 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)----- * format imports---- | Format import list. Imports are alphabetized and grouped into sections--- based on the top level module name (before the first dot). Sections that--- are too small are grouped with the section below them.------ The local imports are sorted and grouped separately from the package--- imports. Rather than being alphabetical, they are sorted in a per-project--- order that should be general-to-specific.------ An unqualified import will follow a qualified one. The Prelude, if--- imported, always goes first.-formatGroups :: Format -> Order -> [Types.ImportLine] -> String-formatGroups format order imports =- unlines $ joinGroups- [ showGroups (group (Util.sortOn packagePrio package))- , showGroups (group (Util.sortOn localPrio local))- , showGroups [Util.sortOn name unqualified]- ]- where- packagePrio import_ =- ( name import_ /= prelude- , name import_- , qualifiedPrio import_- )- localPrio import_ =- ( localPriority (_importOrder order) (Types.importModule import_)- , name import_- , qualifiedPrio import_- )- qualifiedPrio = not . qualifiedImport- name = Types.importModule- (unqualified, local, package) = Util.partition2- ((_sortUnqualifiedLast order &&) . isUnqualified . Types.importDecl)- ((==Types.Local) . Types.importSource)- imports- group- | _groupImports format = collapse . Util.groupOn topModule- | otherwise = (:[])- topModule = takeWhile (/='.') . Types.moduleName . Types.importModule- collapse [] = []- collapse (x:xs)- | length x <= 2 = case collapse xs of- [] -> [x]- y : ys -> (x ++ y) : ys- | otherwise = x : collapse xs- showGroups = List.intercalate [""] . map (map (showImport format))- joinGroups = List.intercalate [""] . filter (not . null)- prelude = Types.ModuleName "Prelude"--isUnqualified :: Haskell.ImportDecl a -> Bool-isUnqualified imp = not (Haskell.importQualified imp)- && Maybe.isNothing (Haskell.importSpecs imp)---- | Modules whose top level element is in 'importFirst' go first, ones in--- 'importLast' go last, and the rest go in the middle.------ Like 'searchPrio' but for order.-localPriority :: Priority ModulePattern -> Types.ModuleName- -> (Int, Maybe Int)-localPriority prio import_ =- case List.findIndex (`matchModule` import_) firsts of- Just k -> (-1, Just k)- Nothing -> case List.findIndex (`matchModule` import_) lasts of- Nothing -> (0, Nothing)- Just k -> (1, Just k)- where- firsts = high prio- lasts = low prio--qualifiedImport :: Types.ImportLine -> Bool-qualifiedImport = Haskell.importQualified . Types.importDecl--showImport :: Format -> Types.ImportLine -> String-showImport format (Types.ImportLine decl cmts _) =- above ++ showImportDecl format decl- ++ (if null right then "" else ' ' : right)- where- above = concat [cmt ++ "\n" | Types.Comment Types.CmtAbove cmt <- cmts]- right = Util.join "\n" [cmt | Types.Comment Types.CmtRight cmt <- cmts]--showImportDecl :: Format -> Types.ImportDecl -> String-showImportDecl format = PP.renderStyle style . prettyImportDecl format- where- style = Haskell.style- { Haskell.lineLength = _columns format- , Haskell.ribbonsPerLine = 1- }---- showImportDecl format = case _ppConfig format of--- Nothing -> Haskell.prettyPrintStyleMode style Haskell.defaultMode--- Just config -> PP.renderStyle style . prettyImportDecl config--- where--- style = Haskell.style--- { Haskell.lineLength = _columns format--- , Haskell.ribbonsPerLine = 1--- }--prettyImportDecl :: Format -> Haskell.ImportDecl a -> PP.Doc-prettyImportDecl format- (Haskell.ImportDecl _ m qual src safe mbPkg mbName mbSpecs) = do- mySep- [ "import"- , if qual || not (_leaveSpaceForQualified format) then mempty- else PP.text (replicate (length ("qualified" :: String)) ' ')- , if src then "{-# SOURCE #-}" else mempty- , if safe then "safe" else mempty- , if qual then "qualified" else mempty- , maybe mempty (\s -> PP.text (show s)) mbPkg- , nameOf m- , maybe mempty (\m -> "as" <+> nameOf m) mbName- , maybe mempty prettyImportSpecList mbSpecs- ]- where- nameOf (Haskell.ModuleName _ s) = PP.text s---- ** Language.Haskell.Exts.Pretty copy-paste---- Language.Haskell.Exts.Pretty doesn't export the pretty method.------ Ironically the haskell-src-exts complains about having to copy paste--- PP.punctuate, which is no longer necessary, while forcing me to copy paste--- much more, by committing the same mistake.--prettyImportSpecList :: Haskell.ImportSpecList a -> PP.Doc-prettyImportSpecList (Haskell.ImportSpecList _ b ispecs) =- (if b then "hiding" else mempty) <+> parenList (map prettyISpec ispecs)--prettyISpec :: Haskell.ImportSpec a -> PP.Doc-prettyISpec = \case- Haskell.IVar _ name -> prettyName name- Haskell.IAbs _ ns name -> prettyNamespace ns <+> prettyName name- Haskell.IThingAll _ name -> prettyName name <> PP.text "(..)"- Haskell.IThingWith _ name nameList ->- prettyName name <> (parenList . map prettyCName $ nameList)--prettyName :: Haskell.Name l -> PP.Doc-prettyName name = case name of- Haskell.Symbol _ ('#':_) -> PP.char '(' <+> ppName name <+> PP.char ')'- _ -> parensIf (isSymbolName name) (ppName name)--prettyCName :: Haskell.CName l -> PP.Doc-prettyCName = \case- Haskell.VarName _ n -> prettyName n- Haskell.ConName _ n -> prettyName n--prettyNamespace :: Haskell.Namespace l -> PP.Doc-prettyNamespace = \case- Haskell.NoNamespace {} -> mempty- Haskell.TypeNamespace {} -> "type"- Haskell.PatternNamespace {} -> "pattern"--parensIf :: Bool -> PP.Doc -> PP.Doc-parensIf True = PP.parens-parensIf False = id--isSymbolName :: Haskell.Name l -> Bool-isSymbolName (Haskell.Symbol {}) = True-isSymbolName _ = False---ppName :: Haskell.Name l -> PP.Doc-ppName (Haskell.Ident _ s) = PP.text s-ppName (Haskell.Symbol _ s) = PP.text s--parenList :: [PP.Doc] -> PP.Doc-parenList = PP.parens . PP.fsep . PP.punctuate PP.comma--pretty :: Haskell.Pretty a => a -> PP.Doc-pretty _ = "?"--mySep :: [PP.Doc] -> PP.Doc-mySep [] = error "mySep got empty"-mySep [x] = x-mySep (x:xs) = x <+> PP.fsep xs---- * 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
− src/Config_test.hs
@@ -1,178 +0,0 @@-{-# LANGUAGE DisambiguateRecordFields #-}-{-# LANGUAGE OverloadedStrings #-}-module Config_test where-import qualified Data.Map as Map-import qualified Data.Text as Text-import qualified EL.Test.Testing as Testing-import qualified Language.Haskell.Exts as Haskell--import qualified Config-import qualified FixImports-import qualified Types--import EL.Test.Global---test_parse = do- let f = check . Config.parse . Text.unlines- check (config, errs)- | null errs = Right config- | otherwise = Left $ Text.unlines errs- equal (f []) $ Right Config.empty- leftLike (f ["include: a", "include: b"]) "duplicate fields"- equal (Config._order <$>- f ["import-order-first: A", "import-order-last: B"]) $- Right $ Config.Order- { _importOrder = Config.Priority ["A"] ["B"]- , _sortUnqualifiedLast = False- }--test_parseUnqualified = do- let f = Config._unqualified . parseConfig- equal (f ["unqualified: A.B (c); D.E ((+))"]) $ Map.fromList- [ (Haskell.Ident () "c", "A.B")- , (Haskell.Symbol () "+", "D.E")- ]- equal (f ["unqualified: A.B(c, d)"]) $ Map.fromList- [ (Haskell.Ident () "c", "A.B")- , (Haskell.Ident () "d", "A.B")- ]- equal (f ["unqualified: A.B (c,(+))"]) $ Map.fromList- [ (Haskell.Ident () "c", "A.B")- , (Haskell.Symbol () "+", "A.B")- ]--test_parseQualifyAs = do- let f = Config._qualifyAs . parseConfig- equal (f ["qualify-as: A.B as AB; E as F"]) $ Map.fromList- [ ("AB", "A.B")- , ("F", "E")- ]- stringsLike (snd $ Config.parse "qualify-as: gibble gabble")- ["stanza should look like"]--test_pickModule = do- let f config modulePath candidates = Config.pickModule- (Config._modulePriority (parseConfig config))- modulePath candidates- equal (f [] "X.hs" []) Nothing- let localAB = [(Nothing, "A.M"), (Nothing, "B.M")]- equal (f [] "X.hs" localAB) $- Just (Nothing, "A.M")- equal (f ["prio-module-high: B.M"] "X.hs" localAB) $- Just (Nothing, "B.M")- -- Has to be an exact match.- equal (f ["prio-module-high: B"] "X.hs" localAB) $- Just (Nothing, "A.M")-- -- Local modules take precedence.- equal (f [] "A/B.hs" [(Nothing, "B.M"), (Just "pkg", "B.M")]) $- Just (Nothing, "B.M")- equal (f [] "A/B/C.hs" [(Nothing, "A.B.M"), (Just "pkg", "B.M")]) $- Just (Nothing, "A.B.M")- -- Closer local modules precede further ones.- equal (f [] "A/B/C.hs" [(Nothing, "A.B.M"), (Nothing, "A.M")]) $- Just (Nothing, "A.B.M")- -- Prefer fewer dots.- equal (f [] "X.hs" [(Just "p1", "A.B.M"), (Just "p2", "Z.M")]) $- Just (Just "p2", "Z.M")--test_formatGroups = do- let f config imports = lines $ Config.formatGroups Config.defaultFormat- (Config._order (parseConfig config))- (Testing.expectRight (parse (unlines imports)))- equal (f [] []) []- -- Unqualified import-all goes last.- equal (f ["sort-unqualified-last: t"]- [ "import Z", "import A"- , "import qualified C", "import qualified B"- , "import C (a)"- ])- [ "import qualified B"- , "import qualified C"- , "import C (a)"- , ""- , "import A"- , "import Z"- ]-- equal (f [] ["import qualified Z", "import qualified A"])- [ "import qualified A"- , "import qualified Z"- ]- equal (f ["import-order-first: Z"]- ["import qualified Z", "import qualified A"])- [ "import qualified Z"- , "import qualified A"- ]- equal (f ["import-order-last: A"]- ["import qualified Z", "import qualified A"])- [ "import qualified Z"- , "import qualified A"- ]-- -- Exact match.- equal (f ["import-order-first: Z"]- ["import qualified Z.A", "import qualified A"])- [ "import qualified A"- , "import qualified Z.A"- ]- -- Unless it's a prefix match.- equal (f ["import-order-first: Z."]- ["import qualified Z.A", "import qualified A"])- [ "import qualified Z.A"- , "import qualified A"- ]--test_showImportLine = do- let f = fmap (Config.showImportDecl style . Types.importDecl . head) . parse- style = Config.defaultFormat { Config._leaveSpaceForQualified = True }- -- pprint $ fmap (Types.importDecl . head) . parse $ "import A.B as C (x)"- equal (f "import A.B as C (x)") $ Right "import A.B as C (x)"--test_leaveSpaceForQualified = do- let f columns leaveSpace =- fmap (Config.showImportDecl (fmt columns leaveSpace) . head- . map Types.importDecl)- . parse- fmt columns leaveSpace = Config.defaultFormat- { Config._columns = columns- , Config._leaveSpaceForQualified = leaveSpace- }- equal (f 80 False "import Foo.Bar (a, b, c)") $- Right "import Foo.Bar (a, b, c)"- equal (f 80 True "import Foo.Bar (a, b, c)") $- Right "import Foo.Bar (a, b, c)"-- equal (f 20 False "import Foo.Bar (a, b, c)") $- Right "import Foo.Bar\n (a, b, c)"- equal (f 30 True "import Foo.Bar (a, b, c)") $- Right "import Foo.Bar\n (a, b, c)"-- equal (f 30 True "import Foo.Bar (tweetle, beetle, paddle, battle)") $ Right- "import Foo.Bar\n\- \ (tweetle, beetle,\n\- \ paddle, battle)"---- * util--importLine :: Types.ImportDecl -> Types.ImportLine-importLine decl = Types.ImportLine- { importDecl = decl- , importComments = []- , importSource = Types.Local- }--parse :: String -> Either String [Types.ImportLine]-parse source = case FixImports.parse [] "" source of- Haskell.ParseFailed srcloc err ->- Left $ Haskell.prettyPrint srcloc ++ ": " ++ err- Haskell.ParseOk (mod, _cmts) ->- Right $ map importLine $ FixImports.moduleImportDecls mod--parseConfig :: [Text.Text] -> Config.Config-parseConfig lines- | null errs = config- | otherwise = error $ "parsing " <> show lines <> ": "- <> Text.unpack (Text.unlines errs)- where (config, errs) = Config.parse (Text.unlines lines)
− src/FixImports.hs
@@ -1,650 +0,0 @@-{- | Automatically fix the import list in a haskell module.-- The process is as follows:-- - Parse the entire file and extract the Qualification of qualified names- like @A.b@, which is simple @A@.-- - Combine this with the modules imported to decide which imports can be- removed and which ones must be added.-- - For added imports, guess the complete import path implied by the- Qualification. This requires some heuristics:-- - Check local modules first. Start in the current module's directory- and then try from the current directory, descending recursively.-- - If no local modules are found, check the package database. There is- a system of package priorities so that @List@ will yield @Data.List@- from @base@ rather than @List@ from @haskell98@. After that, shorter- matches are prioritized so @System.Process@ is chosen over- @System.Posix.Process@.-- - If the module is not found at all, an error is printed on stderr and- the unchanged file on stdout.-- - Of course the heuristics may get the wrong module, but existing- imports are left alone so you can edit them by hand.-- - Then imports are sorted, grouped, and a new module is written to stdout- with the new import block replacing the old one.-- - The default import formatting separates package imports from local- imports, and groups them by their toplevel module name (before the- first dot). Small groups are combined. They go in alphabetical order- by default, but a per-project order may be defined.--}-{-# LANGUAGE BangPatterns #-}-{-# LANGUAGE DisambiguateRecordFields #-}-{-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE RankNTypes #-}-{-# LANGUAGE TupleSections #-}-module FixImports where-import Prelude hiding (mod)-import Control.Applicative ((<$>))-import qualified Control.Monad.State.Strict as State-import qualified Control.DeepSeq as DeepSeq-import Control.Monad.Trans (lift)-import Data.Bifunctor (first)-import qualified Data.Char as Char-import qualified Data.Either as Either-import qualified Data.Generics.Uniplate.Data as Uniplate-import qualified Data.List as List-import qualified Data.Map as Map-import qualified Data.Maybe as Maybe-import qualified Data.Set as Set-import qualified Data.Text as Text-import Data.Text (Text)-import qualified Data.Time.Clock as Clock-import qualified Data.Tuple as Tuple--import qualified Language.Haskell.Exts as Haskell-import qualified Language.Haskell.Exts.Extension as Extension-import qualified Language.Preprocessor.Cpphs as Cpphs--import qualified Numeric-import qualified System.Directory as Directory-import qualified System.FilePath as FilePath-import System.FilePath ((</>))--import qualified Config-import qualified Index-import qualified Types-import qualified Util--import Control.Monad----- | Look only this deep for local modules.-searchDepth :: Int-searchDepth = 12--data Result = Result {- resultRange :: (Row, Row)- , resultImports :: String- , resultAdded :: Set.Set Types.ModuleName- , resultRemoved :: Set.Set Types.ModuleName- , resultMetrics :: [Metric]- } deriving (Show)---- | Line number in the input file.-type Row = Int--type Metric = (Clock.UTCTime, Text)--addMetrics :: [Metric] -> Result -> Result-addMetrics ms result = result { resultMetrics = ms ++ resultMetrics result }--fixModule :: Config.Config -> FilePath -> String- -> IO (Either String Result, [Text])-fixModule config modulePath source = do- mStart <- metric () "start"- processedSource <- cppModule modulePath source- mCpp <- metric () "cpp"- case parse (Config._language config) modulePath processedSource of- Haskell.ParseFailed srcloc err ->- return (Left $ Haskell.prettyPrint srcloc ++ ": " ++ err, [])- Haskell.ParseOk (mod, cmts) -> do- mParse <- metric (mod `seq` (), cmts) "parse"- index <- Index.load- mLoad <- metric () "load-index"- fmap (fmap List.reverse) $ flip State.runStateT [] $- fmap (addMetrics [mStart, mCpp, mParse, mLoad]) <$>- fixImports ioFilesystem config index modulePath mod cmts--parse :: [Extension.Extension] -> FilePath -> String- -> Haskell.ParseResult- (Haskell.Module Haskell.SrcSpanInfo, [Haskell.Comment])-parse extensions modulePath =- Haskell.parseFileContentsWithComments $ Haskell.defaultParseMode- { Haskell.parseFilename = modulePath- , Haskell.extensions = extensions ++ defaultExtensions- -- The meaning of Nothing is undocumented, but I think it means- -- to not check for fixity ambiguity at all, which is what I want.- , Haskell.fixities = Nothing- }- where- defaultExtensions = map Extension.EnableExtension $- Extension.toExtensionList Extension.Haskell2010 []- -- GHC has this extension enabled by default, and it's easy- -- to wind up with code that relies on it:- -- http://www.haskell.org/ghc/docs/7.6.3/html/users_guide/bugs-and-infelicities.html#infelicities-syntax- ++ [Extension.NondecreasingIndentation]---- | The parse function takes a CPP extension, but doesn't actually pay any--- attention to it, so I have to run CPP myself. The imports are fixed--- post-CPP so if you put CPP in the imports block it will be stripped out.--- It seems hard to fix imports inside CPP.-cppModule :: FilePath -> String -> IO String-cppModule filename = Cpphs.runCpphs options filename- where- options = Cpphs.defaultCpphsOptions { Cpphs.boolopts = boolOpts }- boolOpts = Cpphs.defaultBoolOptions- { Cpphs.macros = True- , Cpphs.locations = False- , Cpphs.hashline = False- , Cpphs.pragma = False- , Cpphs.stripEol = True- , Cpphs.stripC89 = True- , Cpphs.lang = True -- lex input as haskell code- , Cpphs.ansi = True- , Cpphs.layout = True- , Cpphs.literate = False -- untested with literate code- , Cpphs.warnings = False- }---- | Capture all the IO operations needed by fixImports, so I can test without--- IO. I could have used Free, but the operations are few, so it seemed--- simpler to factor them out.-data Filesystem m = Filesystem {- _listDir :: FilePath -> m ([FilePath], [FilePath]) -- ^ (dirs, files)- , _doesFileExist :: FilePath -> m Bool- , _metric :: forall a. DeepSeq.NFData a => a -> Text -> m Metric- }--ioFilesystem :: Filesystem IO-ioFilesystem = Filesystem- { _listDir = \ dir -> do- fns <- Maybe.fromMaybe [] <$> Util.catchENOENT (Util.listDir dir)- Util.partitionM Directory.doesDirectoryExist fns- , _doesFileExist = Directory.doesFileExist- , _metric = metric- }--type LogT m a = State.StateT [Text] m a--debug :: Monad m => Config.Config -> Text -> LogT m ()-debug config msg = when (Config._debug config) $ State.modify' (msg:)- -- The check is unnecessary since I check debug before printing them, but- -- it'll save a thunk at least.---- | Take a parsed module along with its unparsed text. Generate a new import--- block with proper spacing, formatting, and comments. Then snip out the--- import block on the import file, and replace it.-fixImports :: Monad m => Filesystem m -> Config.Config -> Index.Index- -> FilePath -> Types.Module -> [Haskell.Comment]- -> LogT m (Either String Result)-fixImports fs config index modulePath mod cmts = do- let (newImports, unusedImports, imports, range) = importInfo mod cmts- mProcess <- lift $ _metric fs- (length newImports, length unusedImports, length imports, range)- "process"- mbNew <- mapM (findNewImport fs config modulePath index)- (Set.toList newImports)- mNewImports <- lift $ _metric fs mbNew "find-new-imports"- (imports, newUnqualImports, unusedUnqual) <- return $- fixUnqualified config mod imports- newUnqualImports <- lift $ mapM (locateImport fs) newUnqualImports- mUnqual <- lift $ _metric fs newUnqualImports "unqualified-imports"- mbExisting <- mapM (findImport fs index (Config._includes config)) imports- mExistingImports <- lift $ _metric fs mbExisting "find-existing-imports"- let existing = map (Types.importDeclModule . fst) imports- let (notFound, importLines) = Either.partitionEithers $- zipWith mkError- (map toModule (Set.toList newImports) ++ existing)- (mbNew ++ mbExisting)- mkError _ (Just imp) = Right imp- mkError mod Nothing = Left mod- let formattedImports =- Config.formatGroups (Config._format config) (Config._order config)- (importLines ++ newUnqualImports)- return $ case notFound of- _ : _ -> Left $ "not found: "- ++ Util.join ", " (map Types.moduleName notFound)- [] -> Right $ Result- { resultRange = range- , resultImports = formattedImports- , resultAdded = Set.fromList $- map (Types.importDeclModule . Types.importDecl) $- Maybe.catMaybes mbNew ++ newUnqualImports- , resultRemoved = unusedImports <> Set.fromList unusedUnqual- , resultMetrics =- [mProcess, mNewImports, mExistingImports, mUnqual]- }- where- toModule (Types.Qualification name) = Types.ModuleName name--locateImport :: Monad m => Filesystem m -> ImportLine -> m Types.ImportLine-locateImport fs (decl, cmts) = do- isLocal <- _doesFileExist fs $- Types.moduleToPath $ Types.importDeclModule decl- return $ Types.ImportLine- { importDecl = decl- , importComments = cmts- , importSource = if isLocal then Types.Local else Types.Package- }---- | This is like 'Types.ImportLine', except without Local/Package info.-type ImportLine = (Types.ImportDecl, [Types.Comment])----- | Add unqualified imports.------ - Get unqualifieds.--- - If _unqualified non-empty, filter them to the ones in _unqualified.--- - Add or modify import lines for them.--- - Remove imports that don't appear in unqualifiedImports-fixUnqualified :: Config.Config -> Types.Module -> [ImportLine]- -> ([ImportLine], [ImportLine], [Types.ModuleName])- -- ^ (modified, new, removed)-fixUnqualified config mod imports =- -- Add new references.- -- Then filter out managed symbols which are no longer present.- -- Then delete empty imports which are now empty.- removeEmptyImports . first (map stripReferences)- . foldr addReferences (imports, []) . Map.toList $ references- where- removeEmptyImports (modified, new) =- (kept, new, map (Types.importDeclModule . fst) removed)- where- (kept, removed) = List.partition (not . emptyImport . fst) modified- references = unqualifiedImports config mod- emptyImport decl = and- [ Map.member moduleName moduleToNames- -- Can delete if it has an import list, but it's empty.- , hasEmptyImportList decl- ]- where moduleName = Types.importDeclModule decl-- stripReferences (decl, cmts) =- ( modifyImportSpecs (filter (keep (Types.importDeclModule decl))) decl- , cmts- )- where- -- Keep if it's not managed, or it is managed and referenced.- keep moduleName name = not (isManaged moduleName name)- || maybe False (name `elem`) (Map.lookup moduleName references)- addReferences (moduleName, names) (existing, new) =- case Util.modifyAt (matches moduleName . fst) add existing of- Nothing -> (existing, (newImport, []) : new)- Just modified -> (modified, new)- where- add = first $ modifyImportSpecs (names++)- newImport = modifyImportSpecs (names++) $- mkImportDecl moduleName Nothing True- matches name decl = isUnqualifiedImport decl- && Types.importDeclModule decl == name- isUnqualifiedImport decl = not (Haskell.importQualified decl)- && maybe True (not . importSpecsHiding) (Haskell.importSpecs decl)-- isManaged moduleName name = maybe False (name `elem`) $- Map.lookup moduleName moduleToNames- moduleToNames :: Map.Map Types.ModuleName [Haskell.Name ()]- moduleToNames = Util.multimap . map Tuple.swap . Map.toList- . Config._unqualified $ config---- | This field seems to be undocumented.-importSpecsHiding :: Haskell.ImportSpecList a -> Bool-importSpecsHiding (Haskell.ImportSpecList _ hiding _) = hiding--hasEmptyImportList :: Haskell.ImportDecl a -> Bool-hasEmptyImportList decl = case Haskell.importSpecs decl of- Just (Haskell.ImportSpecList _ _ specs) -> null specs- Nothing -> False---- | If this import has a import list, modify its contents.------ I strip SrcSpanInfo from the Names because otherwise sorting and unique'ing--- is too finicky.-modifyImportSpecs :: ([Haskell.Name ()] -> [Haskell.Name ()])- -> Types.ImportDecl -> Types.ImportDecl-modifyImportSpecs modify decl = case Haskell.importSpecs decl of- Just specs | not (importSpecsHiding specs) -> decl- { Haskell.importSpecs = Just $ modifySpecs specs- }- _ -> decl- where- -- hiding should be False due to the match above.- modifySpecs (Haskell.ImportSpecList span _hiding specs) =- Haskell.ImportSpecList span False $ Set.toList $ Set.fromList $- map makeVar (doModify vars) ++ others- where- (vars, others) = Util.partitionOn varOf specs- varOf (Haskell.IVar _ name) = Just name- varOf (Haskell.IAbs _ (Haskell.NoNamespace _) name) = Just name- varOf _ = Nothing- doModify = map addSpan . modify . map stripSpan- -- IAbs is constructors and classes, but I can disambiguate just by seeing- -- if it's capitalized.- makeVar name- | isCaps name = Haskell.IAbs noSpan (Haskell.NoNamespace noSpan) name- | otherwise = Haskell.IVar noSpan name- isCaps (Haskell.Ident _ name) = all Char.isUpper (take 1 name)- isCaps _ = False--stripSpan :: Functor f => f Haskell.SrcSpanInfo -> f ()-stripSpan = fmap (const ())--addSpan :: Functor f => f () -> f Haskell.SrcSpanInfo-addSpan = fmap (const noSpan)--unqualifiedImports :: Config.Config -> Types.Module- -> Map.Map Types.ModuleName [Haskell.Name ()]-unqualifiedImports config mod- | unqual == mempty = mempty- | otherwise = Util.multimap $- Maybe.mapMaybe (\k -> (, k) <$> Map.lookup k unqual) $- map stripSpan $ moduleUnqualifieds mod- where unqual = Config._unqualified config---- | Clip out the range from the given text and replace it with the given--- lines.-substituteImports :: String -> (Int, Int) -> String -> String-substituteImports imports (start, end) source =- unlines pre ++ imports ++ unlines post- where- (pre, within) = splitAt start (lines source)- (_, post) = splitAt (end-start) within---- * find new imports---- | Make a new ImportLine from a ModuleName.-findNewImport :: Monad m => Filesystem m -> Config.Config -> FilePath- -> Index.Index -> Types.Qualification- -> LogT m (Maybe Types.ImportLine)- -- ^ Nothing if the module wasn't found-findNewImport fs config modulePath index qual =- fmap make <$> findModule fs config index modulePath qual- where- make (mod, source) = Types.ImportLine- { importDecl = mkImportDecl mod (Just qual) False- , importComments = []- , importSource = source- }--mkImportDecl :: Types.ModuleName -> Maybe Types.Qualification -> Bool- -> Types.ImportDecl-mkImportDecl (Types.ModuleName name) qualification withImportList =- Haskell.ImportDecl- { Haskell.importAnn = noSpan- , Haskell.importModule = Haskell.ModuleName noSpan name- , Haskell.importQualified = Maybe.isJust qualification- , Haskell.importSrc = False- , Haskell.importSafe = False- , Haskell.importPkg = Nothing- , Haskell.importAs = Haskell.ModuleName noSpan <$> importAs- , Haskell.importSpecs = if withImportList- then Just $ Haskell.ImportSpecList noSpan False []- else Nothing- }- where- importAs- -- | Just (Types.Qualification importAs) <- mbImportAs = Just importAs- | Just (Types.Qualification q) <- qualification, q /= name = Just q- | otherwise = Nothing--noSpan :: Haskell.SrcSpanInfo-noSpan = Haskell.noInfoSpan (Haskell.SrcSpan "" 0 0 0 0)---- | Find the qualification and its ModuleName and whether it was a Types.Local--- or Types.Package module. Nothing if it wasn't found at all.-findModule :: Monad m => Filesystem m -> Config.Config -> Index.Index- -> FilePath -- ^ Path to the module being fixed.- -> Types.Qualification -> LogT m (Maybe (Types.ModuleName, Types.Source))-findModule fs config index modulePath qual = do- local <- map fnameToModule <$> ((++)- <$> findLocalModules fs (Config._includes config) qual- <*> maybe (pure []) (findLocalModules fs (Config._includes config))- qualifyAs)- let package = map (first Just) $- findPackageModules qual ++ maybe [] findPackageModules qualifyAs- debug config $ "findModule " <> showt qual <> " from "- <> showt modulePath <> ": local " <> showt local- <> "\npackage: " <> showt package- let prio = Config._modulePriority config- return $ case Config.pickModule prio modulePath (local++package) of- Just (package, mod) -> Just- (mod, if package == Nothing then Types.Local else Types.Package)- Nothing -> Nothing- where- findPackageModules q = Map.findWithDefault [] q index- fnameToModule fn = (Nothing, Types.pathToModule fn)- qualifyAs = Map.lookup qual (Config._qualifyAs config)---- If it's in Config._qualifyAs, then I also search for exactly that module--- name.---- | Given A.B, look for A/B.hs, */A/B.hs, */*/A/B.hs, etc. in each of the--- include paths.-findLocalModules :: Monad m => Filesystem m -> [FilePath]- -> Types.Qualification -> LogT m [FilePath]-findLocalModules fs includes (Types.Qualification name) =- fmap concat . forM includes $ \dir -> map (stripDir dir) <$>- findFiles fs searchDepth (Types.moduleToPath (Types.ModuleName name))- dir--stripDir :: FilePath -> FilePath -> FilePath-stripDir dir path- | dir == "." = path- | otherwise = dropWhile (=='/') $ drop (length dir) path--findFiles :: Monad m => Filesystem m- -> Int -- ^ Descend into subdirectories this many times.- -> FilePath -- ^ Find files with this suffix. Can contain slashes.- -> FilePath -- ^ Start from this directory. Return [] if it doesn't exist.- -> LogT m [FilePath]-findFiles fs depth file dir = do- (subdirs, fns) <- lift $ _listDir fs dir- subfns <- if depth > 0- then concat <$> mapM (findFiles fs (depth-1) file)- (filter isModuleDir subdirs)- else return []- return $ filter sameSuffix fns ++ subfns- where- isModuleDir = all Char.isUpper . take 1 . FilePath.takeFileName- sameSuffix fn = fn == file || ('/' : file) `List.isSuffixOf` fn----- * figure out existing imports---- | Make an existing import into an ImportLine by finding out if it's a local--- module or a package module.-findImport :: Monad m => Filesystem m -> Index.Index -> [FilePath] -> ImportLine- -> LogT m (Maybe Types.ImportLine)-findImport fs index includes (imp, cmts) = do- found <- findModuleName fs index includes (Types.importDeclModule imp)- return $ case found of- Nothing -> Nothing- Just source -> Just $ Types.ImportLine- { importDecl = imp- , importComments = cmts- , importSource = source- }---- | True if it was found in a local directory, False if it was found in the--- ghc package db, and Nothing if it wasn't found at all.-findModuleName :: Monad m => Filesystem m -> Index.Index -> [FilePath]- -> Types.ModuleName -> LogT m (Maybe Types.Source)-findModuleName fs index includes mod = do- isLocal <- lift $ isLocalModule fs mod ("" : includes)- return $- if isLocal then Just Types.Local- else if isPackageModule index mod then Just Types.Package- else Nothing--isLocalModule :: Monad m => Filesystem m -> Types.ModuleName -> [FilePath]- -> m Bool-isLocalModule fs mod =- Util.anyM (_doesFileExist fs . (</> Types.moduleToPath mod))--isPackageModule :: Index.Index -> Types.ModuleName -> Bool-isPackageModule index (Types.ModuleName name) =- Map.member (Types.Qualification name) index---- * util--importInfo :: Types.Module -> [Haskell.Comment]- -> ( Set.Set Types.Qualification- , Set.Set Types.ModuleName- , [ImportLine]- , (Int, Int)- )- -- ^ (missingImports, unusedImports, unchangedImports, rangeOfImportBlock)-importInfo mod cmts = (missing, unused, declCmts, range)- where- unused = Set.difference (Set.fromList modules)- (Set.fromList (map (Types.importDeclModule . fst) declCmts))- missing = Set.difference used imported- -- If the Prelude isn't explicitly imported, it's implicitly imported, so- -- if I see Prelude.x it doesn't mean to add an import.- imported = Set.fromList $ prelude : qualifiedImports-- -- Get from the qualified import name back to the actual module name so- -- I can return that.- modules = map Types.importDeclModule imports- qualifiedImports = map Types.importDeclQualification imports- used = Set.fromList (moduleQNames mod)- imports = moduleImportDecls mod- declCmts =- [ imp- | imp@(decl, _) <- associateComments imports- (filterImportCmts range cmts)- , keepImport decl- ]- -- Keep unqualified imports, but only keep qualified ones if they are used.- -- Prelude is considered always used if it appears, because removing it- -- changes import behavour.- keepImport decl =- Set.member (Types.importDeclQualification decl)- (Set.insert prelude used)- || not (Haskell.importQualified decl)- prelude = Types.Qualification "Prelude"- range = importRange mod--filterImportCmts :: (Int, Int) -> [Haskell.Comment] -> [Haskell.Comment]-filterImportCmts (start, end) = filter inRange- where- inRange (Haskell.Comment _ src _) = start <= s && s < end- where s = Haskell.srcSpanStartLine src - 1 -- spans are 1-based----- | Pair ImportDecls up with the comments that apply to them. Comments--- below the last import are dropped, but there shouldn't be any of those--- because they should have been omitted from the comment block.------ Spaces between comments above an import will be lost, and multiple comments--- to the right of an import (e.g. commenting a complicated import list) will--- probably be messed up. TODO Fix it if it becomes a problem.-associateComments :: [Types.ImportDecl] -> [Haskell.Comment] -> [ImportLine]-associateComments imports cmts = snd $ List.mapAccumL associate cmts imports- where- associate cmts imp = (after, (imp, associated))- where- associated = map (Types.Comment Types.CmtAbove . cmtText) above- ++ map (Types.Comment Types.CmtRight . cmtText) right- -- cmts that end before the import beginning are above it- (above, rest) = List.span ((< start impSpan) . end . cmtSpan) cmts- -- remaining cmts that start before or at the import's end are right- -- of it- (right, after) = List.span ((<= end impSpan) . start . cmtSpan) rest- impSpan = Haskell.srcInfoSpan (Haskell.importAnn imp)- cmtSpan (Haskell.Comment _ span _) = span- cmtText (Haskell.Comment True _ s) = "{-" ++ s ++ "-}"- cmtText (Haskell.Comment False _ s) = "--" ++ s- start = Haskell.srcSpanStartLine- end = Haskell.srcSpanEndLine--moduleImportDecls :: Types.Module -> [Types.ImportDecl]-moduleImportDecls (Haskell.Module _ _ _ imports _) = imports-moduleImportDecls _ = []---- | Return half-open line range of import block, starting from (0 based) line--- of first import to the line after the last one.-importRange :: Types.Module -> (Int, Int)-importRange mod = case mod of- -- Haskell.Module _ mb_head _ imports _ ->- -- let start = headEnd mb_head- -- in (start, max start (importsEnd imports))- Haskell.Module _ Nothing _ imports _ ->- (importsStart imports, importsEnd imports)- Haskell.Module _ (Just modHead) _ imports _ ->- let start = headEnd modHead- in (start, max start (importsEnd imports))- _ -> (0, 0)- where- -- The parser counts lies from 1, but I return a half-open range from 0.- -- So I don't need to +1 the last line of the head, and since the range- -- is half-open I don't need to -1 the last line of the imports.- headEnd (Haskell.ModuleHead src _ _ _) = endOf src- importsStart [] = 0- importsStart (importDecl : _) =- startOf (Haskell.importAnn importDecl) - 1- importsEnd [] = 0- importsEnd imports = (endOf . Haskell.importAnn . last) imports-- startOf = Haskell.srcSpanStartLine . Haskell.srcInfoSpan- endOf = Haskell.srcSpanEndLine . Haskell.srcInfoSpan---- | Uniplate is rad.-moduleQNames :: Types.Module -> [Types.Qualification]-moduleQNames mod =- [ Types.moduleToQualification m- | Haskell.Qual _ m _ <- Uniplate.universeBi mod- ]--moduleUnqualifieds :: Types.Module -> [Types.Name]-moduleUnqualifieds mod =- [name | Haskell.UnQual _ name <- Uniplate.universeBi mod]---- * metrics--metric :: DeepSeq.NFData a => a -> Text -> IO Metric-metric val name = do- force val- flip (,) name <$> Clock.getCurrentTime--showMetrics :: [Metric] -> Text-showMetrics = Text.unlines . format . map diff . Util.zipPrev . Util.sortOn fst- where- format metricDurs =- map (format1 total) (metricDurs ++ [("total", total)])- where total = sum (map snd metricDurs)- format1 total (metric, dur) = Text.unwords- [ justifyR 8 (showDuration dur)- , justifyR 3 (percent (realToFrac dur / realToFrac total))- , "-", metric- ]- diff ((prev, _), (cur, metric)) =- (metric, cur `Clock.diffUTCTime` prev)--force :: DeepSeq.NFData a => a -> IO ()-force x = DeepSeq.rnf x `seq` return ()--percent :: Double -> Text-percent = (<>"%") . showt . isInt . round . (*100)- where- isInt :: Int -> Int- isInt = id--showDuration :: Clock.NominalDiffTime -> Text-showDuration =- Text.pack . ($"s") . Numeric.showFFloat (Just 2) . isDouble . realToFrac- where- isDouble :: Double -> Double- isDouble = id--justifyR :: Int -> Text -> Text-justifyR width = Text.justifyRight width ' '--showt :: Show a => a -> Text-showt = Text.pack . show
+ src/FixImports/Config.hs view
@@ -0,0 +1,333 @@+{-# 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
+ src/FixImports/Config_test.hs view
@@ -0,0 +1,87 @@+{-# LANGUAGE DisambiguateRecordFields #-}+{-# LANGUAGE OverloadedStrings #-}+module FixImports.Config_test where+import qualified Data.Map as Map+import qualified Data.Text as Text++import qualified FixImports.Config as Config+import qualified FixImports.FixImports as FixImports+import qualified FixImports.Types as Types++import EL.Test.Global+++test_parse = do+ let f = check . Config.parse . Text.unlines+ check (config, errs)+ | null errs = Right config+ | otherwise = Left $ Text.unlines errs+ equal (f []) $ Right Config.empty+ leftLike (f ["include: a", "include: b"]) "duplicate fields"+ equal (Config._order <$>+ f ["import-order-first: A", "import-order-last: B"]) $+ Right $ Config.Order+ { _importOrder = Config.Priority ["A"] ["B"]+ , _sortUnqualifiedLast = False+ }+ rightEqual+ (Config._leaveSpaceForQualified . Config._format <$>+ f ["format:", " leave-space-for-qualified"])+ True++test_parseUnqualified = do+ let f = Config._unqualified . parseConfig+ equal (f ["unqualified: A.B (c); D.E ((+))"]) $ Map.fromList+ [ (Types.Name "c", "A.B")+ , (Types.Operator "+", "D.E")+ ]+ equal (f ["unqualified: A.B(c, d)"]) $ Map.fromList+ [ (Types.Name "c", "A.B")+ , (Types.Name "d", "A.B")+ ]+ equal (f ["unqualified: A.B (c,(+))"]) $ Map.fromList+ [ (Types.Name "c", "A.B")+ , (Types.Operator "+", "A.B")+ ]++test_parseQualifyAs = do+ let f = Config._qualifyAs . parseConfig+ equal (f ["qualify-as: A.B as AB; E as F"]) $ Map.fromList+ [ ("AB", "A.B")+ , ("F", "E")+ ]+ stringsLike (snd $ Config.parse "qualify-as: gibble gabble")+ ["stanza should look like"]++test_pickModule = do+ let f config modulePath candidates = Config.pickModule+ (Config._modulePriority (parseConfig config))+ modulePath candidates+ equal (f [] "X.hs" []) Nothing+ let localAB = [(Nothing, "A.M"), (Nothing, "B.M")]+ equal (f [] "X.hs" localAB) $+ Just (Nothing, "A.M")+ equal (f ["prio-module-high: B.M"] "X.hs" localAB) $+ Just (Nothing, "B.M")+ -- Has to be an exact match.+ equal (f ["prio-module-high: B"] "X.hs" localAB) $+ Just (Nothing, "A.M")++ -- Local modules take precedence.+ equal (f [] "A/B.hs" [(Nothing, "B.M"), (Just "pkg", "B.M")]) $+ Just (Nothing, "B.M")+ equal (f [] "A/B/C.hs" [(Nothing, "A.B.M"), (Just "pkg", "B.M")]) $+ Just (Nothing, "A.B.M")+ -- Closer local modules precede further ones.+ equal (f [] "A/B/C.hs" [(Nothing, "A.B.M"), (Nothing, "A.M")]) $+ Just (Nothing, "A.B.M")+ -- Prefer fewer dots.+ equal (f [] "X.hs" [(Just "p1", "A.B.M"), (Just "p2", "Z.M")]) $+ Just (Just "p2", "Z.M")++parseConfig :: [Text.Text] -> Config.Config+parseConfig lines+ | null errs = config+ | otherwise = error $ "parsing " <> show lines <> ": "+ <> Text.unpack (Text.unlines errs)+ where (config, errs) = Config.parse (Text.unlines lines)
+ src/FixImports/FixImports.hs view
@@ -0,0 +1,590 @@+{- | Automatically fix the import list in a haskell module.++ The process is as follows:++ - Parse the entire file and extract the Qualification of qualified names+ like @A.b@, which is simple @A@.++ - Combine this with the modules imported to decide which imports can be+ removed and which ones must be added.++ - For added imports, guess the complete import path implied by the+ Qualification. This requires some heuristics:++ - Check local modules first. Start in the current module's directory+ and then try from the current directory, descending recursively.++ - If no local modules are found, check the package database. There is+ a system of package priorities so that @List@ will yield @Data.List@+ from @base@ rather than @List@ from @haskell98@. After that, shorter+ matches are prioritized so @System.Process@ is chosen over+ @System.Posix.Process@.++ - If the module is not found at all, an error is printed on stderr and+ the unchanged file on stdout.++ - Of course the heuristics may get the wrong module, but existing+ imports are left alone so you can edit them by hand.++ - Then imports are sorted, grouped, and a new module is written to stdout+ with the new import block replacing the old one.++ - The default import formatting separates package imports from local+ imports, and groups them by their toplevel module name (before the+ first dot). Small groups are combined. They go in alphabetical order+ by default, but a per-project order may be defined.+-}+{-# LANGUAGE BangPatterns #-}+{-# LANGUAGE DisambiguateRecordFields #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE RankNTypes #-}+{-# LANGUAGE TupleSections #-}+module FixImports.FixImports where+import Prelude hiding (mod)+import qualified Control.Monad.State.Strict as State+import qualified Control.DeepSeq as DeepSeq+import Control.Monad.Trans (lift)+import Data.Bifunctor (first, 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 Data.Map (Map)+import qualified Data.Maybe as Maybe+import qualified Data.Set as Set+import Data.Set (Set)+import qualified Data.Text as Text+import qualified Data.Text.IO as Text.IO+import Data.Text (Text)+import qualified Data.Time.Clock as Clock+import qualified Data.Tuple as Tuple+import qualified Numeric+import qualified System.Directory as Directory+import qualified System.FilePath as FilePath+import System.FilePath ((</>))++import qualified Language.Preprocessor.Cpphs as Cpphs++import qualified FixImports.Config as Config+import qualified FixImports.Format as Format+import qualified FixImports.Index as Index+import qualified FixImports.Parse as Parse+import qualified FixImports.Types as Types+import qualified FixImports.Util as Util++import Control.Monad+++-- | Look only this deep in the directory hierarchy for local modules.+searchDepth :: Int+searchDepth = 12++data Result = Result {+ resultRange :: (Row, Row)+ , resultImports :: String+ , resultAdded :: Set Types.ModuleName+ , resultRemoved :: Set Types.ModuleName+ , resultMetrics :: [Metric]+ } deriving (Show)++-- | Line number in the input file.+type Row = Int++type Metric = (Clock.UTCTime, Text)++addMetrics :: [Metric] -> Result -> Result+addMetrics ms result = result { resultMetrics = ms ++ resultMetrics result }++fixModule :: Config.Config -> FilePath -> String+ -> IO (Either String Result, [Text])+fixModule config modulePath source = do+ mStart <- metric () "start"+ processedSource <- cppModule modulePath source+ mCpp <- metric () "cpp"+ result <- parse (Config._language config) modulePath processedSource+ case result of+ Left err -> return (Left err, [])+ Right (mod, cmts) -> do+ mParse <- metric (mod `seq` (), cmts) "parse"+ (index, indexFrom) <- Index.load+ when (Config._debug config) $ Text.IO.putStr $+ "index from " <> indexFrom <> ":\n" <> Index.showIndex index+ mLoad <- metric () "load-index"+ let extracted = extract config mod cmts+ mExtract <- metric extracted "extract"+ case checkForCpp (_importRange extracted) source of+ [] -> fmap (fmap List.reverse) $ flip State.runStateT [] $+ fmap (addMetrics [mStart, mCpp, mParse, mLoad, mExtract]) <$>+ fixImports ioFilesystem config index modulePath extracted+ cpps -> return+ ( Left $ "can't handle CPP directives in import block:\n"+ <> unlines cpps+ , []+ )++parse :: [Types.Extension] -> FilePath -> String+ -> IO (Either String (Parse.Module, [Parse.Comment]))+parse extensions modulePath = Parse.parse extensions modulePath++-- | The parse function takes a CPP extension, but doesn't actually pay any+-- attention to it, so I have to run CPP myself. The imports are fixed+-- post-CPP so if you put CPP in the imports block it will be stripped out.+-- It seems hard to fix imports inside CPP.+cppModule :: FilePath -> String -> IO String+cppModule filename = Cpphs.runCpphs options filename+ where+ options = Cpphs.defaultCpphsOptions { Cpphs.boolopts = boolOpts }+ boolOpts = Cpphs.defaultBoolOptions+ { Cpphs.macros = True+ , Cpphs.locations = False+ , Cpphs.hashline = False+ , Cpphs.pragma = False+ , Cpphs.stripEol = True+ , Cpphs.stripC89 = True+ , Cpphs.lang = True -- lex input as haskell code+ , Cpphs.ansi = True+ , Cpphs.layout = True+ , Cpphs.literate = False -- untested with literate code+ , Cpphs.warnings = False+ }++-- | I have to get the CPP out before parsing and fixing imports, but then it's+-- hard to put it back in again. Especially the main reason for CPP is+-- conditional imports, which means I might not even know what to do with them.+-- I suppose I could try to detect them and preserve them, but for now it's+-- simpler to just abort on any CPP. At least it's better than silently+-- deleting it.+checkForCpp :: (Row, Row) -> String -> [String]+checkForCpp (start, end) =+ map (\(i, line) -> show i <> ":" <> line)+ . filter (any (`Set.member` cppThings) . words . snd)+ . take (end-start) . drop start . zip [1 :: Int ..] . lines+ where+ cppThings = Set.fromList $ map ("#"<>)+ [ "define", "undef", "include", "if", "ifdef", "ifndef", "else"+ , "elif", "endif", "line", "error", "pragma"+ ]++-- | Capture all the IO operations needed by fixImports, so I can test without+-- IO. I could have used Free, but the operations are few, so it seemed+-- simpler to factor them out.+data Filesystem m = Filesystem {+ _listDir :: FilePath -> m ([FilePath], [FilePath]) -- ^ (dirs, files)+ , _doesFileExist :: FilePath -> m Bool+ , _metric :: forall a. DeepSeq.NFData a => a -> Text -> m Metric+ }++ioFilesystem :: Filesystem IO+ioFilesystem = Filesystem+ { _listDir = \ dir -> do+ fns <- Maybe.fromMaybe [] <$> Util.catchENOENT (Util.listDir dir)+ Util.partitionM isDir fns+ , _doesFileExist = Directory.doesFileExist+ , _metric = metric+ }+ where+ -- Symlinks are not directories, so I don't walk into them.+ isDir fn = (&&) <$> Directory.doesDirectoryExist fn+ <*> (not <$> Directory.pathIsSymbolicLink fn)++type LogT m a = State.StateT [Text] m a++debug :: Monad m => Config.Config -> Text -> LogT m ()+debug config msg = when (Config._debug config) $ State.modify' (msg:)+ -- The check is unnecessary since I check debug before printing them, but+ -- it'll save a thunk at least.++-- | Take a parsed module along with its unparsed text. Generate a new import+-- block with proper spacing, formatting, and comments. Then snip out the+-- import block on the import file, and replace it.+fixImports :: Monad m => Filesystem m -> Config.Config -> Index.Index+ -> FilePath -> Extracted -> LogT m (Either String Result)+fixImports fs config index modulePath extracted = do+ mbNew <- mapM (findNewImport fs config modulePath index)+ (Set.toList (_missingImports extracted))+ mNewImports <- lift $ _metric fs mbNew "find-new-imports"+ (imports, newUnqualImports, unusedUnqual) <- return $+ fixUnqualified (_modToUnqualifieds extracted) config+ (_unchangedImports extracted)+ newUnqualImports <- lift $ mapM (locateImport fs) newUnqualImports+ mUnqual <- lift $ _metric fs newUnqualImports "unqualified-imports"+ mbExisting <- mapM (findImport fs index (Config._includes config)) imports+ mExistingImports <- lift $ _metric fs mbExisting "find-existing-imports"+ let existing = map (Types._importName . fst) imports+ let (notFound, importLines) = Either.partitionEithers $+ zipWith mkError+ (map qualToMod (Set.toList (_missingImports extracted))+ ++ existing)+ (mbNew ++ mbExisting)+ mkError _ (Just imp) = Right imp+ mkError mod Nothing = Left mod+ let formattedImports =+ Format.formatGroups (Config._format config) (Config._order config)+ (importLines ++ newUnqualImports)+ return $ case notFound of+ _ : _ -> Left $ "not found: "+ ++ Util.join ", " (map Types.moduleName notFound)+ [] -> Right $ Result+ { resultRange = _importRange extracted+ , resultImports = formattedImports+ , resultAdded = Set.fromList $+ map (Types._importName . Types.importDecl) $+ Maybe.catMaybes mbNew ++ newUnqualImports+ , resultRemoved =+ _unusedImports extracted <> Set.fromList unusedUnqual+ , resultMetrics = [mNewImports, mExistingImports, mUnqual]+ }+ where+ qualToMod (Types.Qualification name) = Types.ModuleName name++type ImportComment = (Types.Import, [Types.Comment])++locateImport :: Monad m => Filesystem m -> ImportComment -> m Types.ImportLine+locateImport fs (decl, cmts) = do+ isLocal <- _doesFileExist fs $ Types.moduleToPath $ Types._importName decl+ return $ Types.ImportLine+ { importDecl = decl+ , importComments = cmts+ , importSource = if isLocal then Types.Local else Types.Package+ }++-- | Add unqualified imports.+--+-- - Get unqualifieds.+-- - If _unqualified non-empty, filter them to the ones in _unqualified.+-- - Add or modify import lines for them.+-- - Remove imports that don't appear in modToUnqualifieds.+fixUnqualified :: Map Types.ModuleName (Set Types.Name)+ -> Config.Config -> [ImportComment]+ -> ([ImportComment], [ImportComment], [Types.ModuleName])+ -- ^ (modified, new, removed)+fixUnqualified modToUnqualifieds config imports =+ removeEmptyImports moduleToNames $+ first (map (first stripReferences)) $+ foldr addReferences (imports, []) $+ filter (not . Set.null . snd) $+ map (second (Set.filter (not . alreadyImported))) $+ Map.toList modToUnqualifieds+ where+ -- Ignore unqualified references that are already imported, perhaps from+ -- some other module.+ alreadyImported :: Types.Name -> Bool+ alreadyImported name = any ((name `elem`) . importedEntities . fst) imports++ -- Successively modify the ImportComment list for each new reference. Keep+ -- existing modified imports separate from newly added ones, so they can be+ -- reported as adds.+ addReferences :: (Types.ModuleName, Set Types.Name)+ -> ([ImportComment], [ImportComment])+ -> ([ImportComment], [ImportComment])+ addReferences (moduleName, names) (existing, new) =+ case Util.modifyAt (matches moduleName . fst) add existing of+ Nothing -> (existing, (newImport, []) : new)+ Just modified -> (modified, new)+ where+ add = first $ Types.importModify (newEntities ++)+ newImport = (Types.makeImport moduleName)+ { Types._importEntities = Just $ map Right newEntities }+ newEntities = map mkEntity $ Set.toList names+ matches name imp = Types.importUnqualified imp+ && Types._importName imp == name++ -- Remove managed unqualified imports that are no longer referenced.+ stripReferences :: Types.Import -> Types.Import+ stripReferences imp = Types.importModify (filter keep) imp+ where+ moduleName = Types._importName imp+ -- Keep if it's not managed, or it is managed and referenced.+ keep (Types.Entity Nothing var Nothing) =+ not (isManaged moduleName var)+ || maybe False (var `Set.member`)+ (Map.lookup moduleName modToUnqualifieds)+ keep _ = True+ isManaged moduleName name = maybe False (name `elem`) $+ Map.lookup moduleName moduleToNames+ moduleToNames :: Map Types.ModuleName [Types.Name]+ moduleToNames = Util.multimap . map Tuple.swap . Map.toList+ . Config._unqualified $ config+ mkEntity var = Types.Entity Nothing var Nothing++importedEntities :: Types.Import -> [Types.Name]+importedEntities = map Types._entityVar . Either.rights . Maybe.fromMaybe []+ . Types._importEntities++-- | Remove unqualified imports that have been made empty.+removeEmptyImports :: Map Types.ModuleName names -> ([ImportComment], new)+ -> ([ImportComment], new, [Types.ModuleName])+removeEmptyImports moduleToNames (modified, new) =+ (kept, new, map (Types._importName . fst) removed)+ where+ (kept, removed) = List.partition (not . emptyImport . fst) modified+ emptyImport imp = Map.member (Types._importName imp) moduleToNames+ -- Can delete if it has an import list, but it's empty.+ && Types.importEmpty imp+++-- | Make a map from each module with unqualified imports to its unqualified+-- imports that occur in the module.+makeModToUnqualifieds :: Config.Config -> Parse.Module+ -> Map Types.ModuleName (Set Types.Name)+makeModToUnqualifieds config mod+ | unqual == mempty = mempty+ | otherwise = Util.setmap $+ Maybe.mapMaybe (\name -> (, name) <$> Map.lookup name unqual) $+ Set.toList $ Parse.unqualifieds mod+ where unqual = Config._unqualified config++-- | Clip out the range from the given text and replace it with the given+-- lines.+substituteImports :: String -> (Int, Int) -> String -> String+substituteImports imports (start, end) source =+ unlines pre ++ imports ++ unlines post+ where+ (pre, within) = splitAt start (lines source)+ (_, post) = splitAt (end-start) within++-- * find new imports++-- | Make a new ImportLine from a ModuleName.+findNewImport :: Monad m => Filesystem m -> Config.Config -> FilePath+ -> Index.Index -> Types.Qualification+ -> LogT m (Maybe Types.ImportLine)+ -- ^ Nothing if the module wasn't found+findNewImport fs config modulePath index qual =+ fmap make <$> findModule fs config index modulePath qual+ where+ make (mod, source) = Types.ImportLine+ { importDecl = Types.setQualification qual (Types.makeImport mod)+ , importComments = []+ , importSource = source+ }++-- | Find the qualification and its ModuleName and whether it was a Types.Local+-- or Types.Package module. Nothing if it wasn't found at all.+findModule :: Monad m => Filesystem m -> Config.Config -> Index.Index+ -> FilePath -- ^ Path to the module being fixed.+ -> Types.Qualification -> LogT m (Maybe (Types.ModuleName, Types.Source))+findModule fs config index modulePath qual = do+ local <- map fnameToModule <$> ((++)+ <$> findLocalModules fs (Config._includes config) qual+ <*> maybe (pure []) (findLocalModules fs (Config._includes config))+ qualifyAs)+ let package = map (first Just) $+ findPackageModules qual ++ maybe [] findPackageModules qualifyAs+ debug config $ "findModule " <> showt qual <> " from "+ <> showt modulePath <> ": local " <> showt local+ <> "\npackage: " <> showt package+ let prio = Config._modulePriority config+ return $ case Config.pickModule prio modulePath (local++package) of+ Just (package, mod) -> Just+ (mod, if package == Nothing then Types.Local else Types.Package)+ Nothing -> Nothing+ where+ findPackageModules q = Map.findWithDefault [] q index+ fnameToModule fn = (Nothing, Types.pathToModule fn)+ qualifyAs = Map.lookup qual (Config._qualifyAs config)++-- If it's in Config._qualifyAs, then I also search for exactly that module+-- name.++-- | Given A.B, look for A/B.hs, */A/B.hs, */*/A/B.hs, etc. in each of the+-- include paths.+findLocalModules :: Monad m => Filesystem m -> [FilePath]+ -> Types.Qualification -> LogT m [FilePath]+findLocalModules fs includes (Types.Qualification name) =+ fmap concat . forM includes $ \dir -> map (stripDir dir) <$>+ findFiles fs searchDepth (Types.moduleToPath (Types.ModuleName name))+ dir++stripDir :: FilePath -> FilePath -> FilePath+stripDir dir path+ | dir == "." = path+ | otherwise = dropWhile (=='/') $ drop (length dir) path++findFiles :: Monad m => Filesystem m+ -> Int -- ^ Descend into subdirectories this many times.+ -> FilePath -- ^ Find files with this suffix. Can contain slashes.+ -> FilePath -- ^ Start from this directory. Return [] if it doesn't exist.+ -> LogT m [FilePath]+findFiles fs depth file dir = do+ (subdirs, fns) <- lift $ _listDir fs dir+ subfns <- if depth > 0+ then concat <$> mapM (findFiles fs (depth-1) file)+ (filter isModuleDir subdirs)+ else return []+ return $ filter sameSuffix fns ++ subfns+ where+ isModuleDir = all Char.isUpper . take 1 . FilePath.takeFileName+ sameSuffix fn = fn == file || ('/' : file) `List.isSuffixOf` fn+++-- * figure out existing imports++-- | Make an existing import into an ImportLine by finding out if it's a local+-- module or a package module.+findImport :: Monad m => Filesystem m -> Index.Index -> [FilePath]+ -> ImportComment -> LogT m (Maybe Types.ImportLine)+findImport fs index includes (imp, cmts) = do+ found <- findModuleName fs index includes (Types._importName imp)+ return $ case found of+ Nothing -> Nothing+ Just source -> Just $ Types.ImportLine+ { importDecl = imp+ , importComments = cmts+ , importSource = source+ }++-- | True if it was found in a local directory, False if it was found in the+-- ghc package db, and Nothing if it wasn't found at all.+findModuleName :: Monad m => Filesystem m -> Index.Index -> [FilePath]+ -> Types.ModuleName -> LogT m (Maybe Types.Source)+findModuleName fs index includes mod = do+ isLocal <- lift $ isLocalModule fs mod ("" : includes)+ return $+ if isLocal then Just Types.Local+ else if isPackageModule index mod then Just Types.Package+ else Nothing++isLocalModule :: Monad m => Filesystem m -> Types.ModuleName -> [FilePath]+ -> m Bool+isLocalModule fs mod =+ Util.anyM (_doesFileExist fs . (</> Types.moduleToPath mod))++isPackageModule :: Index.Index -> Types.ModuleName -> Bool+isPackageModule index (Types.ModuleName name) =+ Map.member (Types.Qualification name) index++-- * util++-- | All the relevant info extracted from a module.+data Extracted = Extracted {+ -- | References exist, but no corresponding imports.+ _missingImports :: Set Types.Qualification+ -- | Imports exist but no reference.+ , _unusedImports :: Set Types.ModuleName+ , _unchangedImports :: [ImportComment]+ , _importRange :: (Int, Int)+ , _modToUnqualifieds :: Map Types.ModuleName (Set Types.Name)+ }++instance DeepSeq.NFData Extracted where+ rnf (Extracted a b c d e) = DeepSeq.rnf (a, b, c, d, e)++extract :: Config.Config -> Parse.Module -> [Parse.Comment] -> Extracted+extract config mod cmts = Extracted+ { _missingImports = missing+ , _unusedImports = unused+ , _unchangedImports = importCmts+ , _importRange = range+ , _modToUnqualifieds = makeModToUnqualifieds config mod+ }+ where+ unused = Set.difference (Set.fromList modules)+ (Set.fromList (map (Types._importName . fst) importCmts))+ missing = Set.difference used imported+ -- If the Prelude isn't explicitly imported, it's implicitly imported, so+ -- if I see Prelude.x it doesn't mean to add an import.+ imported = Set.fromList $ prelude : qualifiedImports+ importCmts =+ [ impCmt+ | impCmt <- associateComments imports $+ dropWhile before $ List.sort cmts+ , keepImport (fst impCmt)+ ]+ before = (< fst range) . Types._startLine . Parse._span+ range = Parse.importRange mod+ -- Keep unqualified imports, but only keep qualified ones if they are used.+ -- Prelude is considered always used if it appears, because removing it+ -- changes import behavour.+ keepImport imp =+ Set.member (Types.importQualification imp) (Set.insert prelude used)+ || not (Types._importQualified imp)+ prelude = Types.Qualification "Prelude"++ -- Get from the qualified import name back to the actual module name so+ -- I can return that.+ modules = map Types._importName imports+ used = Parse.qualifications mod+ qualifiedImports = map Types.importQualification imports+ imports = normalizeImports $ Parse.extractImports mod++-- | Clean up redundant imports.+normalizeImports :: [Types.Import] -> [Types.Import]+normalizeImports imports =+ Util.uniqueOn key qual+ ++ map merge (Util.groupOn key (Util.sortOn key unqual))+ where+ (qual, unqual) = List.partition Types._importQualified imports+ key imp = imp+ { Types._importEntities = Nothing+ , Types._importSpan = Types.noSpan+ }+ merge group@(imp:_) = imp+ { Types._importEntities = mconcat (map Types._importEntities group) }+ merge [] = error "groupOn postcondition"++-- | Pair Imports up with the comments that apply to them. Comments+-- below the last import are dropped, but there shouldn't be any of those+-- because they should have been omitted from the comment block.+--+-- Spaces between comments above an import will be lost, and multiple comments+-- to the right of an import (e.g. commenting a complicated import list) will+-- probably be messed up. TODO Fix it if it becomes a problem.+associateComments :: [Types.Import] -> [Parse.Comment] -> [ImportComment]+associateComments imports cmts = snd $ List.mapAccumL associate cmts imports+ where+ associate cmts imp = (after, (imp, associated))+ where+ associated = map (Types.Comment Types.CmtAbove . Parse._comment) above+ ++ map (Types.Comment Types.CmtRight . Parse._comment) right+ -- cmts that end before the import beginning are above it+ (above, rest) = List.span ((< start impSpan) . end . Parse._span) cmts+ -- remaining cmts that start before or at the import's end are right+ -- of it+ (right, after) = List.span ((<= end impSpan) . start . Parse._span) rest+ impSpan = Types._importSpan imp+ start = Types._startLine+ end = Types._endLine++-- * metrics++metric :: DeepSeq.NFData a => a -> Text -> IO Metric+metric val name = do+ force val+ flip (,) name <$> Clock.getCurrentTime++showMetrics :: [Metric] -> Text+showMetrics = Text.unlines . format . map diff . Util.zipPrev . Util.sortOn fst+ where+ format metricDurs =+ map (format1 total) (metricDurs ++ [("total", total)])+ where total = sum (map snd metricDurs)+ format1 total (metric, dur) = Text.unwords+ [ justifyR 8 (showDuration dur)+ , justifyR 3 (percent (realToFrac dur / realToFrac total))+ , "-", metric+ ]+ diff ((prev, _), (cur, metric)) =+ (metric, cur `Clock.diffUTCTime` prev)++force :: DeepSeq.NFData a => a -> IO ()+force x = DeepSeq.rnf x `seq` return ()++percent :: Double -> Text+percent = (<>"%") . showt . isInt . round . (*100)+ where+ isInt :: Int -> Int+ isInt = id++showDuration :: Clock.NominalDiffTime -> Text+showDuration =+ Text.pack . ($ "s") . Numeric.showFFloat (Just 2) . isDouble . realToFrac+ where+ isDouble :: Double -> Double+ isDouble = id++justifyR :: Int -> Text -> Text+justifyR width = Text.justifyRight width ' '++showt :: Show a => a -> Text+showt = Text.pack . show
+ src/FixImports/FixImports_test.hs view
@@ -0,0 +1,266 @@+{-# LANGUAGE DisambiguateRecordFields #-}+{-# LANGUAGE OverloadedStrings #-}+module FixImports.FixImports_test where+import Control.Monad (unless, void)+import qualified Control.Monad.Identity as Identity+import qualified Control.Monad.State.Strict as State+import Data.Bifunctor (bimap)+import qualified Data.List as List+import qualified Data.Map as Map+import qualified Data.Maybe as Maybe+import qualified Data.Set as Set+import qualified Data.Text as Text+import qualified Data.Time.Clock.POSIX as Clock.POSIX+import qualified System.IO.Unsafe as Unsafe++import qualified System.FilePath as FilePath++import qualified FixImports.Config as Config+import qualified FixImports.FixImports as FixImports+import qualified FixImports.Index as Index+import qualified FixImports.Types as Types++import EL.Test.Global+++test_simple = do+ let run = fmap FixImports.resultImports+ . fixModule index ["C.hs"] (mkConfig "") "A.hs"+ index = Index.makeIndex+ [ ("pkg", ["A.B"])+ , ("zpkg", ["Z"])+ ]+ rightEqual (run "x = B.c") "import qualified A.B as B\n"+ rightEqual (run "x = A.B.c") "import qualified A.B\n"+ leftLike (run "x = Q.c") "not found: Q"+ -- Remove unused.+ rightEqual (run "import qualified A.B as B\n\nx = y") ""+ -- Unless it's unqualified.+ rightEqual (run "import A.B as B\n\nx = y")+ "import A.B as B\n"++ -- Local goes below package.+ rightEqual (run "x = (B.a, C.a, Z.a)")+ "import qualified A.B as B\n\+ \import qualified Z\n\+ \\n\+ \import qualified C\n"++ -- Don't mess with imports I don't manage.+ rightEqual (run "import A.B hiding (mod)\n") "import A.B hiding (mod)\n"+ rightEqual (run "import A.B\n") "import A.B\n"+ -- remove redundant imports+ rightEqual (run "import qualified Z\nimport qualified Z\nf = Z.a")+ "import qualified Z\n"++test_comments = do+ let run = fmap FixImports.resultImports+ . fixModule index [] (mkConfig "") "A.hs"+ index = Index.makeIndex [("pkg", ["A", "B"])]+ rightEqual (run "import A") "import A\n"+ -- Comments out of the edited range are not affected.+ rightEqual+ (run+ "-- before\n\+ \module M where -- where\+ \-- above\n\+ \import A\n\+ \-- below")+ "import A\n"+ rightEqual+ (run+ "module M where\n\+ \import A\n\+ \-- above1\n\+ \-- above2\n\+ \import B {- right -}\n")++ "import A\n\+ \-- above1\n\+ \-- above2\n\+ \import B {- right -}\n"++test_qualifyAs = do+ let run config files = fmap eResult . fixModule index files config "A.hs"+ config = mkConfig "qualify-as: Data.Text.Lazy as DTL"+ index = Index.makeIndex [("text", ["Data.Text.Lazy"]), ("pkg", ["A"])]+ rightEqual (run config [] "x = DTL.y")+ ( ["Data.Text.Lazy"]+ , []+ , "import qualified Data.Text.Lazy as DTL\n"+ )+ rightEqual (run config [] "import qualified Data.Text.Lazy as DTL\n")+ ( []+ , ["Data.Text.Lazy"]+ , ""+ )++ -- qualifyAs aliases are prioritized in the same way as others, so+ -- the local module wins:+ rightEqual (run config ["DTL.hs"] "x = DTL.y")+ (["DTL"], [], "import qualified DTL\n")++ -- Unless explicitly suppressed:+ let config2 = mkConfig $ Text.unlines+ [ "qualify-as: Data.Text.Lazy as DTL"+ , "prio-module-high: Data.Text.Lazy"+ ]+ rightEqual (run config2 ["DTL.hs"] "x = DTL.y")+ ( ["Data.Text.Lazy"]+ , []+ , "import qualified Data.Text.Lazy as DTL\n"+ )++ -- strip duplicates+ rightEqual (run config2 []+ "import qualified Data.Text.Lazy as DTL\n\+ \import qualified Data.Text.Lazy as DTL\n\+ \x = DTL.y")+ ([], [], "import qualified Data.Text.Lazy as DTL\n")+ -- not confused by other imports with the same name+ rightEqual (run config2 []+ "import qualified A as DTL\n\+ \import qualified Data.Text.Lazy as DTL\n\+ \x = DTL.y")+ ( []+ , []+ , "import qualified A as DTL\nimport qualified Data.Text.Lazy as DTL\n"+ )++test_unqualified = do+ let run config = fmap eResult+ . fixModule index ["C.hs"] (mkConfig ("unqualified: " <> config))+ "A.hs"+ index = Index.makeIndex+ [ ("pkg", ["A.B"])+ , ("zpkg", ["Z"])+ ]+ rightEqual (run "A.B (c)" "x = (c, c)")+ ( ["A.B"]+ , []+ , "import A.B (c)\n"+ )+ -- Modify an existing import.+ rightEqual (run "A.B (c)" "import A.B (a, z)\nx = (c, c)")+ ( []+ , []+ , "import A.B (a, c, z)\n"+ )+ -- Remove an unused one, if I'm managing it.+ rightEqual (run "A.B (a, c)" "import A.B (a, c)\nx = a")+ ( []+ , []+ , "import A.B (a)\n"+ )+ -- Don't accumulate duplicates.+ rightEqual (run "A.B (c)" "import A.B (c)\nx = c")+ ([], [], "import A.B (c)\n")+ rightEqual (run "A.B (C)" "import A.B (C)\nx :: C")+ ([], [], "import A.B (C)\n")+ -- Don't manage it if it's not mine.+ rightEqual (run "A.B (c)" "import A.B (d)")+ ([], [], "import A.B (d)\n")+ -- local still goes below package+ rightEqual (run "C (a)" "import A.B\nimport Z\nx = a")+ ( ["C"]+ , []+ , "import A.B\n\+ \import Z\n\+ \\n\+ \import C (a)\n"+ )+ -- Don't import when it's on the lhs.+ rightEqual (run "A.B (c)" "c = x") ([], [], "")+ rightEqual (run "A.B ((</>))" "x = a </> b")+ (["A.B"], [], "import A.B ((</>))\n")+ -- Add and remove operators+ rightEqual (run "A.B ((</>))" "x = a </> b")+ (["A.B"], [], "import A.B ((</>))\n")+ rightEqual (run "A.B ((</>))" "import A.B ((</>))\nx = a b")+ ([], ["A.B"], "")+ -- Removed unused.+ rightEqual (run "A.B (c)" "import A.B (c)\nx = x\n") ([], ["A.B"], "")+ rightEqual+ (run "A.B (c)"+ "import A.B (c)\nimport A.B (c, d)\nimport A.B (d)\nx = d\n")+ ([], [], "import A.B (d)\n")+ -- But not if it's a everything-import.+ rightEqual (run "A.B (c)" "import A.B\nx = x\n") ([], [], "import A.B\n")+ -- Ignore if it's already imported unqualified.+ rightEqual (run "A.B (c)" "import Z (c)\nx = c") ([], [], "import Z (c)\n")+++-- * implementation++eResult :: FixImports.Result -> ([Types.ModuleName], [Types.ModuleName], String)+eResult r =+ ( Set.toList (FixImports.resultAdded r)+ , Set.toList (FixImports.resultRemoved r)+ , FixImports.resultImports r+ )++fixModule :: Index.Index -> [FilePath]+ -> Config.Config -> FilePath -> String -> Either String FixImports.Result+fixModule index files config modulePath source =+ case Unsafe.unsafePerformIO $+ FixImports.parse (Config._language config) modulePath source of+ Left err -> Left err+ Right (mod, cmts) ->+ fst $ Identity.runIdentity $ flip State.runStateT [] $+ FixImports.fixImports (pureFilesystem files) config index+ modulePath (FixImports.extract config mod cmts)++-- | The ./ stuff is tricky, this is probably still wrong.+pureFilesystem :: [FilePath] -> FixImports.Filesystem Identity.Identity+pureFilesystem files = FixImports.Filesystem+ { _listDir = \dir -> return+ . Maybe.fromMaybe ([], []) . (`Map.lookup` tree) $ dir+ , _doesFileExist = \fn -> return . (`elem` files) . normalize $ fn+ , _metric = \_ _ ->+ return (Clock.POSIX.posixSecondsToUTCTime 0, Text.pack "metric")+ }+ where+ tree = filesToTree (map ("./"++) files)+ normalize ('.':'/':fn) = fn+ normalize fn = fn++-- group by first element, then second, etc.+filesToTree :: [FilePath] -> Map.Map FilePath ([FilePath], [FilePath])+ -- ^ (dirs, files)+filesToTree = Map.fromList+ . map (bimap (List.dropWhileEnd (=='/')) separate) . groupFst+ . concatMap prefixes+ where+ separate [""] = ([], [])+ separate subs = (unique $ map (takeWhile (/='/')) dirs, files)+ where (dirs, files) = List.partition ('/' `elem`) subs++prefixes :: FilePath -> [(FilePath, FilePath)]+prefixes = map (bimap concat concat) . drop 1+ . (\xs -> zip (List.inits xs) (List.tails xs)) . FilePath.splitPath++mkConfig :: Text.Text -> Config.Config+mkConfig content+ | null errs = config { Config._includes = ["."] }+ | otherwise = error $ "parsing " <> show content <> ": "+ <> unlines (map Text.unpack errs)+ where (config, errs) = Config.parse content++-- * util++type NonNull a = [a]++-- | Similar to 'keyedGroupSort', but key on the fst element, and strip the+-- key out of the groups.+groupFst :: Ord a => [(a, b)] -> [(a, NonNull b)]+groupFst xs = [(key, map snd group) | (key, group) <- keyedGroupSort fst xs]++-- | Group the unsorted list into @(key x, xs)@ where all @xs@ compare equal+-- after @key@ is applied to them.+keyedGroupSort :: Ord key => (a -> key) -> [a] -> [(key, NonNull a)]+ -- ^ Sorted by key. The NonNull group is in the same order as the input.+keyedGroupSort key = Map.toAscList . foldr go Map.empty+ where go x = Map.alter (Just . maybe [x] (x:)) (key x)++unique :: Ord a => [a] -> [a]+unique = Set.toList . Set.fromList
+ src/FixImports/Format.hs view
@@ -0,0 +1,133 @@+{-# LANGUAGE OverloadedStrings #-}+module FixImports.Format (+ formatGroups+ , showImport+) where+import qualified Data.List as List+import qualified Data.Maybe as Maybe+import qualified Text.PrettyPrint as PP+import Text.PrettyPrint ((<+>))++import qualified FixImports.Config as Config+import qualified FixImports.Types as Types+import qualified FixImports.Util as Util+++-- | Format import list. Imports are alphabetized and grouped into sections+-- based on the top level module name (before the first dot). Sections that+-- are too small are grouped with the section below them.+--+-- The local imports are sorted and grouped separately from the package+-- imports. Rather than being alphabetical, they are sorted in a per-project+-- order that should be general-to-specific.+--+-- An unqualified import will follow a qualified one. The Prelude, if+-- imported, always goes first.+formatGroups :: Config.Format -> Config.Order -> [Types.ImportLine] -> String+formatGroups format order imports =+ unlines $ joinGroups+ [ showGroups (group (Util.sortOn packagePrio package))+ , showGroups (group (Util.sortOn localPrio local))+ , showGroups [Util.sortOn name unqualified]+ ]+ where+ packagePrio import_ =+ ( name import_ /= prelude+ , name import_+ , qualifiedPrio import_+ )+ localPrio import_ =+ ( localPriority (Config._importOrder order) (name import_)+ , name import_+ , qualifiedPrio import_+ )+ qualifiedPrio = not . Types._importQualified . Types.importDecl+ name = Types._importName . Types.importDecl+ (unqualified, local, package) = Util.partition2+ ((Config._sortUnqualifiedLast order &&) . isUnqualified+ . Types.importDecl)+ ((==Types.Local) . Types.importSource)+ imports+ group+ | Config._groupImports format = collapse . Util.groupOn topModule+ | otherwise = (:[])+ topModule = takeWhile (/='.') . Types.moduleName . name+ collapse [] = []+ collapse (x:xs)+ | length x <= 2 = case collapse xs of+ [] -> [x]+ y : ys -> (x ++ y) : ys+ | otherwise = x : collapse xs+ showGroups = List.intercalate [""] . map (map (showImportLine format))+ joinGroups = List.intercalate [""] . filter (not . null)+ prelude = Types.ModuleName "Prelude"++isUnqualified :: Types.Import -> Bool+isUnqualified imp = not (Types._importQualified imp)+ && Maybe.isNothing (Types._importEntities imp)++-- | Modules whose top level element is in 'importFirst' go first, ones in+-- 'importLast' go last, and the rest go in the middle.+--+-- Like 'searchPrio' but for order.+localPriority :: Config.Priority Config.ModulePattern -> Types.ModuleName+ -> (Int, Maybe Int)+localPriority prio import_ =+ case List.findIndex (`Config.matchModule` import_) firsts of+ Just k -> (-1, Just k)+ Nothing -> case List.findIndex (`Config.matchModule` import_) lasts of+ Nothing -> (0, Nothing)+ Just k -> (1, Just k)+ where+ firsts = Config.high prio+ lasts = Config.low prio++showImportLine :: Config.Format -> Types.ImportLine -> String+showImportLine format (Types.ImportLine imp cmts _) = concat+ [ above+ , showImport format imp+ , (if null right then "" else ' ' : right)+ ]+ where+ above = concat [cmt ++ "\n" | Types.Comment Types.CmtAbove cmt <- cmts]+ right = Util.join "\n" [cmt | Types.Comment Types.CmtRight cmt <- cmts]++showImport :: Config.Format -> Types.Import -> String+showImport format+ (Types.Import name pkgQualifier source safe qualified as hiding+ entities _span) =+ PP.renderStyle style $ PP.hang+ (PP.hsep+ [ "import"+ , if qualified || not (Config._leaveSpaceForQualified format)+ || source || safe+ then mempty+ else PP.text (replicate (length ("qualified" :: String)) ' ')+ , if source then "{-# SOURCE #-}" else mempty+ , if safe then "safe" else mempty+ , if qualified then "qualified" else mempty+ , maybe mempty (\s -> PP.text (show s)) pkgQualifier+ , PP.text $ Types.moduleName name+ , maybe mempty+ (\(Types.Qualification qual) -> "as" <+> PP.text qual) as+ , if hiding then "hiding" else mempty+ ])+ (Config._wrapIndent format) (maybe mempty prettyEntities entities)+ where+ style = PP.style+ { PP.lineLength = Config._columns format+ , PP.ribbonsPerLine = 1+ }++prettyEntities :: [Either Types.Error Types.Entity] -> PP.Doc+prettyEntities = parenList . map pp+ where+ -- This means 'extractEntity' hit something it didn't think should be+ -- possible.+ pp (Left err) = "{{parse error: " <> PP.text err <> "}}"+ pp (Right (Types.Entity qualifier var list)) =+ (maybe mempty PP.text qualifier <+> PP.text (Types.showName var))+ <> maybe mempty PP.text list++parenList :: [PP.Doc] -> PP.Doc+parenList = PP.parens . PP.fsep . PP.punctuate PP.comma
+ src/FixImports/Format_test.hs view
@@ -0,0 +1,114 @@+{-# LANGUAGE DisambiguateRecordFields #-}+{-# LANGUAGE OverloadedStrings #-}+module FixImports.Format_test where+import qualified Data.Text as Text+import qualified System.IO.Unsafe as Unsafe++import qualified FixImports.Config as Config+import qualified FixImports.Format as Format+import qualified FixImports.Parse as Parse+import qualified FixImports.Types as Types++import qualified EL.Test.Testing as Testing+import EL.Test.Global+++test_formatGroups = do+ let f config imports = lines $ Format.formatGroups Config.defaultFormat+ (Config._order (parseConfig config))+ (Testing.expectRight (parse (unlines imports)))+ equal (f [] []) []+ -- Unqualified import-all goes last.+ equal (f ["sort-unqualified-last: t"]+ [ "import Z", "import A"+ , "import qualified C", "import qualified B"+ , "import C (a)"+ ])+ [ "import qualified B"+ , "import qualified C"+ , "import C (a)"+ , ""+ , "import A"+ , "import Z"+ ]++ equal (f [] ["import qualified Z", "import qualified A"])+ [ "import qualified A"+ , "import qualified Z"+ ]+ equal (f ["import-order-first: Z"]+ ["import qualified Z", "import qualified A"])+ [ "import qualified Z"+ , "import qualified A"+ ]+ equal (f ["import-order-last: A"]+ ["import qualified Z", "import qualified A"])+ [ "import qualified Z"+ , "import qualified A"+ ]++ -- Exact match.+ equal (f ["import-order-first: Z"]+ ["import qualified Z.A", "import qualified A"])+ [ "import qualified A"+ , "import qualified Z.A"+ ]+ -- Unless it's a prefix match.+ equal (f ["import-order-first: Z."]+ ["import qualified Z.A", "import qualified A"])+ [ "import qualified Z.A"+ , "import qualified A"+ ]++test_showImport = do+ let f = fmap (Format.showImport style . Types.importDecl . head) . parse+ style = Config.defaultFormat { Config._leaveSpaceForQualified = True }+ equal (f "import A.B as C (x)") $ Right "import A.B as C (x)"++test_leaveSpaceForQualified = do+ let f columns leaveSpace =+ fmap (Format.showImport (fmt columns leaveSpace) . head+ . map Types.importDecl)+ . parse+ fmt columns leaveSpace = Config.defaultFormat+ { Config._columns = columns+ , Config._leaveSpaceForQualified = leaveSpace+ }+ rightEqual (f 80 False "import Foo.Bar (a, b, c)")+ "import Foo.Bar (a, b, c)"+ rightEqual (f 80 True "import Foo.Bar (a, b, c)")+ "import Foo.Bar (a, b, c)"++ rightEqual (f 20 False "import Foo.Bar (a, b, c)")+ "import Foo.Bar\n\+ \ (a, b, c)"+ rightEqual (f 30 True "import Foo.Bar (a, b, c)")+ "import Foo.Bar\n\+ \ (a, b, c)"++ rightEqual (f 30 True "import Foo.Bar (tweetle, beetle, paddle, battle)")+ "import Foo.Bar\n\+ \ (tweetle, beetle, paddle,\n\+ \ battle)"+++-- * util++parse :: String -> Either String [Types.ImportLine]+parse = Unsafe.unsafePerformIO+ . fmap (fmap (map importLine . Parse.extractImports . fst))+ . Parse.parse [] "M.hs"++importLine :: Types.Import -> Types.ImportLine+importLine imp = Types.ImportLine+ { importDecl = imp+ , importComments = []+ , importSource = Types.Local+ }++parseConfig :: [Text.Text] -> Config.Config+parseConfig lines+ | null errs = config+ | otherwise = error $ "parsing " <> show lines <> ": "+ <> Text.unpack (Text.unlines errs)+ where (config, errs) = Config.parse (Text.unlines lines)
+ src/FixImports/Index.hs view
@@ -0,0 +1,174 @@+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TupleSections #-}+-- | Maintain the index from Qualification to the full module from the package+-- db that this Qualification probably intends.+module FixImports.Index (+ Index, Package, empty, load, showIndex, makeIndex, parseSections+) where+import Prelude hiding (mod)+import Control.Monad+import Data.Bifunctor (second)+import Data.Maybe (mapMaybe)+import Data.Text (Text)+import System.FilePath ((</>))+import qualified Data.Either as Either+import qualified Data.List as List+import qualified Data.Map as Map+import qualified Data.Maybe as Maybe+import qualified Data.Set as Set+import qualified Data.Text as Text+import qualified Data.Text.IO as Text.IO+import qualified GHC.Paths++import qualified System.Directory as Directory+import qualified System.IO as IO++import qualified FixImports.PkgCache as PkgCache+import qualified FixImports.Types as Types+import qualified FixImports.Util as Util+++-- | Map from tails of the each module in the package db to its module name.+-- So @List@ and @Data.List@ will map to @Data.List@. Modules from a set+-- of core packages, like base and containers, will take priority, so even if+-- there's a package with @Some.Obscure.List@, @List@ will still map to+-- @Data.List@.+type Index = Map.Map Types.Qualification [(Package, Types.ModuleName)]++-- | Package name without the version.+type Package = String++empty :: Index+empty = Map.empty++load :: IO (Index, Text)+load = fromGhcEnvironment >>= \case+ Just index -> return (index, ".ghc.environment")+ Nothing -> (, "global ghc-pkg") <$> fromGhcPkg+ -- TODO ghc pkg could also use PkgCache to load the global db++showIndex :: Index -> Text+showIndex index = Text.unlines+ [ Text.pack k <> ": " <> Text.pack (show v)+ | (Types.Qualification k, v) <- Map.toAscList index+ ]++-- | I think the global package db is always under the libdir?+bootPkgDb :: FilePath+bootPkgDb = GHC.Paths.libdir </> "package.conf.d"++fromGhcEnvironment :: IO (Maybe Index)+fromGhcEnvironment = parseGhcEnvironment >>= \case+ Nothing -> return Nothing+ Just (pkgDbs, unitIds) -> do+ nameModules <- PkgCache.load (Set.fromList unitIds)+ (bootPkgDb : pkgDbs)+ return $ Just $ makeIndex $+ map (fmap (map Types.ModuleName)) nameModules++-- | The code to write .ghc.environment is in Cabal+-- Distribution.Simple.GHC.Internal, the code to read it is copy pasted over+-- into cabal-install Distribution.Client.CmdInstall. So they're not even+-- thinking of being consistent with themselves, let alone anyone else.+-- Too much bother.+parseGhcEnvironment :: IO (Maybe ([FilePath], [PkgCache.UnitId]))+parseGhcEnvironment = do+ envFiles <- filter (".ghc.environment." `List.isPrefixOf`) <$>+ Directory.listDirectory "."+ case envFiles of+ [] -> return Nothing+ [envFile] -> Just . parseEnvFile <$> Text.IO.readFile envFile+ _ -> error $ "multiple ghc env files: " <> unwords envFiles++parseEnvFile :: Text -> ([FilePath], [PkgCache.UnitId])+parseEnvFile = Either.partitionEithers . mapMaybe parse . Text.lines+ where+ parse line = case Text.words line of+ ["package-db", path] -> Just $ Left $ Text.unpack path+ ["package-id", unit] -> Just $ Right unit+ _ -> Nothing+ -- clear-package-db+ -- global-package-db+ -- package-db /Users/elaforge/.cabal/store/ghc-9.2.5/package.db+ -- package-db dist-newstyle/packagedb/ghc-9.2.5+ -- package-id hlibgit2-0.18.0.16-inplace+ -- package-id base-4.16.4.0+ -- package-id bndngs-DSL-1.0.25-d82df022++fromGhcPkg :: IO Index+fromGhcPkg = do+ (_, out, err) <- Util.readProcessWithExitCode "ghc-pkg"+ ["field", "*", "name,exposed,exposed-modules"]+ unless (Text.null err) $+ IO.hPutStrLn IO.stderr $ "stderr from ghc-pkg: " ++ Text.unpack err+ let (errors, index) = parseDump out+ unless (null errors) $+ IO.hPutStrLn IO.stderr $ "errors parsing ghc-pkg output: "+ ++ List.intercalate ", " errors+ return index++makeIndex :: [(Text, [Types.ModuleName])] -- ^ [(package, modules)]+ -> Index+makeIndex packages = Map.fromListWith (++)+ [ (qual, [(Text.unpack package, mod)])+ | (package, modules) <- packages+ , mod <- modules+ , qual <- moduleQualifications mod+ ]++parseDump :: Text -> ([String], Index)+parseDump text = (errors, makeIndex packages)+ where+ (errors, packages) = Either.partitionEithers $+ extractSections (parseGhcPkg text)++extractSections :: [(Text, [Text])]+ -> [Either String (Text, [Types.ModuleName])]+extractSections = Maybe.mapMaybe extract . Util.splitWith ((=="name") . fst)+ where+ extract [ ("name", [name])+ , ("exposed", [exposed])+ , ("exposed-modules", modules)+ ]+ | exposed /= "True" = Nothing+ | otherwise = Just $+ Right (name, map (Types.ModuleName . Text.unpack) modules)+ -- It may be missing exposed-modules, but that means I don't need it.+ extract _ = Nothing++-- | Take a module name to all its possible qualifications, i.e. its list+-- of suffixes.+moduleQualifications :: Types.ModuleName -> [Types.Qualification]+moduleQualifications = map (Types.Qualification . Util.join ".")+ . filter (not . null) . List.tails . Util.split "." . Types.moduleName++parseGhcPkg :: Text -> [(Text, [Text])]+parseGhcPkg = map (second (map uncomma . concatMap Text.words)) . parseSections+ where+ -- Somewhere in 9.2, ghc-pkg switched from space separated to comma+ -- separated.+ uncomma t = Maybe.fromMaybe t (Text.stripSuffix "," t)++parseSections :: Text -> [(Text, [Text])] -- ^ [(section_name, lines)]+parseSections = List.unfoldr parseSection . stripComments . Text.lines++stripComments :: [Text] -> [Text]+stripComments =+ filter (not . Text.null) . map (Text.stripEnd . fst . Text.breakOn "--")++-- | Consume a "tag: xyz" plus indents until the next dedented section.+parseSection :: [Text] -> Maybe ((Text, [Text]), [Text])+parseSection [] = Nothing+parseSection (x:xs) = Just+ ( (tag, map Text.strip (Text.drop 1 rest : pre))+ , post+ )+ where+ (tag, rest) = Text.break (==':') x+ (pre, post) = span (" " `Text.isPrefixOf`) xs+++-- * read cache++
+ src/FixImports/Main.hs view
@@ -0,0 +1,143 @@+-- | Main file for FixImports that uses the default formatting. It reads+-- a config file from the current directory.+--+-- More documentation in "FixImports".+{-# LANGUAGE DisambiguateRecordFields #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE ScopedTypeVariables #-}+module FixImports.Main where+import qualified Control.Exception as Exception+import Control.Monad (when)+import qualified Data.List as List+import Data.Maybe (fromMaybe)+import qualified Data.Set as Set+import qualified Data.Text as Text+import qualified Data.Text.IO as Text.IO+import qualified Data.Version as Version++import qualified System.Console.GetOpt as GetOpt+import qualified System.Directory as Directory+import qualified System.Environment as Environment+import qualified System.Exit as Exit+import qualified System.IO as IO++import qualified FixImports.Config as Config+import qualified FixImports.FixImports as FixImports+import qualified FixImports.Types as Types+import qualified FixImports.Util as Util++import qualified Paths_fix_imports+++main :: IO ()+main = do+ -- I need the module path to search for modules relative to it first. I+ -- could figure it out from the parsed module name, but a main module may+ -- not have a name.+ (modulePath, flags) <- parseArgs =<< Environment.getArgs+ (config, errors) <-+ fromMaybe (Config.empty, []) <$> case [fn | Config fn <- flags] of+ [] -> findConfig+ fns -> readConfig (last fns)+ if null errors+ then mainConfig config flags modulePath+ else do+ Text.IO.hPutStrLn IO.stderr $ Text.unlines errors+ Exit.exitFailure++findConfig :: IO (Maybe (Config.Config, [Text.Text]))+findConfig = firstJust . map readConfig =<< getConfigLocations++getConfigLocations :: IO [FilePath]+getConfigLocations = sequence+ [ return ".fix-imports"+ , Directory.getXdgDirectory Directory.XdgConfig "fix-imports"+ ]++firstJust :: [IO (Maybe a)] -> IO (Maybe a)+firstJust [] = return Nothing+firstJust (x : xs) = maybe (firstJust xs) (return . Just) =<< x++readConfig :: FilePath -> IO (Maybe (Config.Config, [Text.Text]))+readConfig = fmap (fmap Config.parse) . Util.catchENOENT . Text.IO.readFile++mainConfig :: Config.Config -> [Flag] -> FilePath -> IO ()+mainConfig config flags modulePath = do+ let (verbose, debug, includes) = extractFlags flags+ source <- IO.getContents+ config <- return $ config+ { Config._includes = includes ++ Config._includes config+ , Config._debug = debug+ }+ (result, logs) <- FixImports.fixModule config modulePath source+ `Exception.catch` \(exc :: Exception.SomeException) ->+ return (Left $ "exception: " ++ show exc, [])+ case result of+ Left err -> do+ if Edit `elem` flags then putStrLn "0,0" else putStr source+ when debug $ mapM_ (Text.IO.hPutStrLn IO.stderr) logs+ IO.hPutStrLn IO.stderr $ "error: " ++ err+ Exit.exitFailure+ Right (FixImports.Result range imports added removed metrics) -> do+ if Edit `elem` flags+ then do+ putStrLn $ show (fst range) <> "," <> show (snd range)+ putStr imports+ else putStr $ FixImports.substituteImports imports range source+ let names = Util.join ", " . map Types.moduleName . Set.toList+ (addedMsg, removedMsg) = (names added, names removed)+ mDone <- FixImports.metric metrics "done"+ when debug $ mapM_ (Text.IO.hPutStrLn IO.stderr) logs+ Config.debug config $ Text.stripEnd $+ FixImports.showMetrics (mDone : metrics)+ when (verbose && not (null addedMsg) || not (null removedMsg)) $+ IO.hPutStrLn IO.stderr $ Util.join "; " $ filter (not . null)+ [ if null addedMsg then "" else "added: " ++ addedMsg+ , if null removedMsg then "" else "removed: " ++ removedMsg+ ]+ Exit.exitSuccess++data Flag = Config FilePath | Debug | Edit | Include String | Verbose+ deriving (Eq, Show)++options :: [FilePath] -> [GetOpt.OptDescr Flag]+options configLocations =+ [ GetOpt.Option ['c'] ["config"] (GetOpt.ReqArg Config "path") $+ "path to config file, otherwise will look in "+ <> List.intercalate ", " configLocations+ , GetOpt.Option [] ["edit"] (GetOpt.NoArg Edit)+ "print delete range and new import block, rather than the whole file"+ , GetOpt.Option [] ["debug"] (GetOpt.NoArg Debug)+ "print debugging info on stderr"+ , GetOpt.Option ['i'] [] (GetOpt.ReqArg Include "path")+ "add to module include path"+ , GetOpt.Option ['v'] [] (GetOpt.NoArg Verbose)+ "print added and removed modules on stderr"+ ]++parseArgs :: [String] -> IO (String, [Flag])+parseArgs args = do+ configLocations <- getConfigLocations+ case GetOpt.getOpt GetOpt.Permute (options configLocations) args of+ (flags, [modulePath], []) -> return (modulePath, flags)+ (_, [], errs) -> usage $ concat errs+ _ -> usage "too many args"++extractFlags :: [Flag] -> (Bool, Bool, [FilePath])+extractFlags flags =+ ( Verbose `elem` flags+ , Debug `elem` flags+ , "." : [p | Include p <- flags]+ )+ -- Includes always have the current directory first.++usage :: String -> IO a+usage msg = do+ name <- Environment.getProgName+ configLocations <- getConfigLocations+ IO.hPutStr IO.stderr $+ GetOpt.usageInfo (msg ++ "\n" ++ name ++ " Module.hs <Module.hs")+ (options configLocations)+ IO.hPutStrLn IO.stderr $+ "version: " ++ Version.showVersion Paths_fix_imports.version+ Exit.exitFailure
+ src/FixImports/Parse.hs view
@@ -0,0 +1,281 @@+{-# LANGUAGE DisambiguateRecordFields #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE GeneralizedNewtypeDeriving #-}+{-# LANGUAGE LambdaCase #-}+{-# LANGUAGE NamedFieldPuns #-}+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE ScopedTypeVariables #-}+{-# LANGUAGE TypeSynonymInstances #-}+module FixImports.Parse (+ -- * types+ Module+ , Comment(..)+ -- * parse+ , parse+ -- * extract+ , qualifications+ , unqualifieds+ , importRange+ -- ** Import+ , extractImports++ -- TESTING+ , unqualifiedValues, unqualifiedTypes+) where+import Control.Applicative ((<|>))+import qualified Control.DeepSeq as DeepSeq+import qualified Control.Monad as Monad+import qualified Data.Char as Char+import qualified Data.List as List+import qualified Data.Maybe as Maybe+import Data.Maybe (fromMaybe)+import qualified Data.Set as Set++import qualified Language.Haskell.GhclibParserEx.GHC.Parser as Parser+import qualified Language.Haskell.GhclibParserEx.GHC.Settings.Config as Config+import qualified Language.Haskell.GhclibParserEx.GHC.Driver.Session as ExSession++import qualified GHC.Data.FastString as FastString+import qualified GHC.Driver.Session as Session+import qualified GHC.Hs as Hs+import qualified GHC.Parser.Annotation as Annotation+import qualified GHC.Parser.Lexer as Lexer+import qualified GHC.Types.Name.Occurrence as Occurrence+import qualified GHC.Types.Name.Reader as Reader+import qualified GHC.Types.SourceText as SourceText+import qualified GHC.Types.SrcLoc as SrcLoc+import qualified GHC.Data.Bag as Bag+import qualified GHC.Unit.Module.Name as Name+import qualified GHC.Unit.Types as Unit.Types+import qualified GHC.Parser.Errors.Ppr as Errors.Ppr++import qualified Data.Generics.Uniplate.Data as Uniplate++import qualified FixImports.Types as Types+import qualified FixImports.Util as Util+++-- * parse++type Module = Hs.HsModule++type Src = String++-- | makeDynFlags forces parse into IO. The reason seems to be that GHC+-- puts dyn flags in a global variable.+parse :: [Types.Extension] -> FilePath -> Src+ -> IO (Either String (Hs.HsModule, [Comment]))+parse extensions filename src = makeDynFlags extensions filename src >>= \case+ Left err -> return $ Left $ "parsing pragmas: " <> err+ Right dynFlags -> return $+ extractParseResult dynFlags $ Parser.parseFile filename dynFlags src++data Comment = Comment { _span :: !Types.SrcSpan, _comment :: !String }+ deriving (Eq, Ord, Show)++instance DeepSeq.NFData Comment where rnf (Comment _ cmt) = DeepSeq.rnf cmt++makeDynFlags :: [Types.Extension] -> FilePath -> Src+ -> IO (Either String Session.DynFlags)+makeDynFlags extensions fname src =+ ExSession.parsePragmasIntoDynFlags defaultDynFlags+ (extensions ++ defaultExtensions, []) fname src+ where defaultExtensions = Session.languageExtensions Nothing++defaultDynFlags :: Session.DynFlags+defaultDynFlags =+ Session.defaultDynFlags Config.fakeSettings Config.fakeLlvmConfig++extractParseResult :: Session.DynFlags+ -> Lexer.ParseResult (SrcLoc.GenLocated l Module)+ -> Either String (Module, [Comment])+extractParseResult _dynFlags = \case+ Lexer.POk state val -> Right+ ( SrcLoc.unLoc val+ , List.sort $ map extractComment (Lexer.comment_q state)+ ++ maybe [] (map extractComment) (Lexer.header_comments state)+ ++ importComments (SrcLoc.unLoc val)+ -- Lexer.annotations_comments I think is supposed to have comments+ -- associated with their "attached" SrcSpan, whatever that is.+ -- In any case, it's empty for comments in the import block at least.+ )+ Lexer.PFailed state -> Left $ unlines $ concat+ [ map (("warn: "<>) . show . Errors.Ppr.pprWarning) $+ Bag.bagToList warns+ , map (("error: "<>) . show . Errors.Ppr.pprError) $+ Bag.bagToList errors+ ]+ where (warns, errors) = Lexer.getMessages state++extractComment :: Hs.LEpaComment -> Comment+extractComment cmt =+ Comment (extractRealSrcSpan (Annotation.anchor (SrcLoc.getLoc cmt))) $+ case Annotation.ac_tok (SrcLoc.unLoc cmt) of+ Annotation.EpaDocCommentNext s -> s+ Annotation.EpaDocCommentPrev s -> s+ Annotation.EpaDocCommentNamed s -> s+ Annotation.EpaDocSection _depth s -> s+ Annotation.EpaDocOptions s -> s+ Annotation.EpaLineComment s -> s+ Annotation.EpaBlockComment s -> s+ Annotation.EpaEofComment -> ""++-- * extract++importComments :: Hs.HsModule -> [Comment]+importComments =+ map extractComment+ . concatMap (extract . extractAnn . Annotation.ann . SrcLoc.getLoc)+ . Hs.hsmodImports+ where+ extractAnn = \case+ Annotation.EpAnn _anchor _anns comments -> comments+ Annotation.EpAnnNotUsed -> Annotation.emptyComments+ extract = \case+ Annotation.EpaComments prior -> prior+ Annotation.EpaCommentsBalanced prior following -> prior ++ following++-- | Qualifications of all the qualified names in this module.+qualifications :: Module -> Set.Set Types.Qualification+qualifications mod = Set.fromList+ [ Types.Qualification $ Name.moduleNameString moduleName+ | Reader.Qual moduleName _occName <- Uniplate.universeBi mod+ ]++-- | All unqualified names which are referenced in the module. This is a lot+-- more complicated than 'qualifications' because this needs to omit unqualified+-- names which are defined in here.+unqualifieds :: Module -> Set.Set Types.Name+unqualifieds mod = unqualifiedTypes mod <> unqualifiedValues mod++-- | TODO: this doesn't handle type operators yet.+unqualifiedTypes :: Module -> Set.Set Types.Name+unqualifiedTypes mod = Set.fromList $ do+ hsType :: Hs.HsType Hs.GhcPs <- Uniplate.universeBi mod+ Reader.Unqual occName <- Uniplate.universeBi hsType+ let var = Occurrence.occNameString occName+ -- I don't want type variables, since they aren't references. I can just+ -- filter out lower-case, which seems to be good enough?+ Monad.guard $ case var of+ c : _ -> Char.isUpper c+ _ -> False+ return $ inferName var++unqualifiedValues :: Module -> Set.Set Types.Name+unqualifiedValues mod = Set.fromList $ do+ (Hs.FunBind { fun_matches } :: Hs.HsBindLR Hs.GhcPs Hs.GhcPs)+ <- Uniplate.universeBi mod+ let matches = map SrcLoc.unLoc $ SrcLoc.unLoc $ Hs.mg_alts fun_matches+ -- I think 'pats' is the binding names.+ -- let pats = concatMap Hs.m_pats matches+ let rhss = map Hs.m_grhss matches+ Reader.Unqual occName <- Uniplate.universeBi rhss+ return $ inferName $ Occurrence.occNameString occName++-- | Return half-open line range of import block, starting from (0 based) line+-- of first import to the line after the last one.+importRange :: Module -> (Int, Int)+importRange mod =+ get . unzip . map range+ . Maybe.mapMaybe (getSpan . Annotation.locA . SrcLoc.getLoc)+ . Hs.hsmodImports $ mod+ where+ -- This range is 1-based inclusive, and I want 0-based half-open, so+ -- subtract 1 from the start.+ get :: ([Int], [Int]) -> (Int, Int)+ get (starts@(_:_), ends@(_:_)) = (minimum starts - 1, maximum ends)+ -- No imports, pick the line after export list or module header.+ get _ = fromMaybe (0, 0) $ do+ span <- getSpan =<<+ (Annotation.locA . SrcLoc.getLoc <$> Hs.hsmodExports mod)+ <|> (Annotation.locA . SrcLoc.getLoc <$> Hs.hsmodName mod)+ return (SrcLoc.srcSpanEndLine span, SrcLoc.srcSpanEndLine span)+ range span = (SrcLoc.srcSpanStartLine span, SrcLoc.srcSpanEndLine span)+ getSpan (SrcLoc.RealSrcSpan span _) = Just span+ getSpan _ = Nothing++-- ** Import++extractImports :: Module -> [Types.Import]+extractImports = map extractImport . Hs.hsmodImports++extractImport :: Hs.LImportDecl Hs.GhcPs -> Types.Import+extractImport locDecl = Types.Import+ { _importName = Types.ModuleName $ Name.moduleNameString $ SrcLoc.unLoc $+ Hs.ideclName decl+ , _importPkgQualifier = FastString.unpackFS . SourceText.sl_fs <$>+ Hs.ideclPkgQual decl+ , _importIsBoot = Hs.ideclSource decl == Unit.Types.IsBoot+ , _importSafe = Hs.ideclSafe decl+ -- I don't distinguish Hs.QualifiedPost+ , _importQualified = Hs.ideclQualified decl /= Hs.NotQualified+ , _importAs = Types.Qualification . Name.moduleNameString . SrcLoc.unLoc+ <$> Hs.ideclAs decl+ , _importHiding = maybe False fst $ Hs.ideclHiding decl+ , _importEntities = map (extractEntity . SrcLoc.unLoc) . SrcLoc.unLoc+ . snd <$> Hs.ideclHiding decl+ , _importSpan = extractSrcSpan $ Annotation.locA $ SrcLoc.getLoc locDecl++ }+ where decl = SrcLoc.unLoc locDecl++extractSrcSpan :: SrcLoc.SrcSpan -> Types.SrcSpan+extractSrcSpan (SrcLoc.RealSrcSpan span _) = extractRealSrcSpan span+extractSrcSpan (SrcLoc.UnhelpfulSpan fstr) =+ error $ "UnhelpfulSpan: " <> show fstr+ -- I think GHC uses these internally, in phases after Hs.GhcPs.++extractRealSrcSpan :: SrcLoc.RealSrcSpan -> Types.SrcSpan+extractRealSrcSpan span = Types.SrcSpan+ -- GHC SrcSpan has 1-based lines, I use 0-based ones.+ { _startLine = SrcLoc.srcSpanStartLine span - 1+ , _startCol = SrcLoc.srcSpanStartCol span+ , _endLine = SrcLoc.srcSpanEndLine span - 1+ , _endCol = SrcLoc.srcSpanEndCol span+ }++extractEntity :: Hs.IE Hs.GhcPs -> Either Types.Error Types.Entity+extractEntity = fmap entity . \case+ -- var+ Hs.IEVar _ var -> Right (unvar var, Nothing)+ -- Constructor+ Hs.IEThingAbs _ var -> Right (unvar var, Nothing)+ -- Constructor(..)+ Hs.IEThingAll _ var -> Right (unvar var, Just "(..)")+ -- Constructor(x, y)+ -- What is _wildcard?+ Hs.IEThingWith _ var _wildcard things -> Right+ ( unvar var+ , Just $ "(" <> List.intercalate ", " (map (varStr . unvar) things)+ <> ")"+ )+ -- Shouldn't happen, export only.+ Hs.IEModuleContents {} -> Left "IEModuleContents"+ Hs.IEGroup {} -> Left "IEGroup"+ Hs.IEDoc {} -> Left "IEDoc"+ Hs.IEDocNamed {} -> Left "IEDocNamed"+ where+ entity ((qual, var), list) = Types.Entity qual var list+ unvar var = case SrcLoc.unLoc var of+ Hs.IEName n -> (Nothing, toName n)+ Hs.IEPattern _ n -> (Just "pattern", toName n)+ Hs.IEType _ n -> (Just "type", toName n)+ varStr (Just qual, name) = qual <> " " <> Types.showName name+ varStr (Nothing, name) = Types.showName name+ toName = inferName . unRdrName . SrcLoc.unLoc++inferName :: String -> Types.Name+inferName var+ | all Util.haskellOpChar var = Types.Operator var+ | otherwise = Types.Name var++unRdrName :: Reader.RdrName -> String+unRdrName = \case+ Reader.Unqual occName -> Occurrence.occNameString occName+ Reader.Qual mod occName ->+ Name.moduleNameString mod <> "." <> Occurrence.occNameString occName+ -- TODO what is this?+ Reader.Orig _mod occName -> -- modName mod <>+ "+" <> Occurrence.occNameString occName+ -- TODO what is this?+ Reader.Exact _name -> "exact"
+ src/FixImports/Parse_test.hs view
@@ -0,0 +1,157 @@+{-# LANGUAGE DisambiguateRecordFields #-}+{-# LANGUAGE OverloadedStrings #-}+module FixImports.Parse_test where+import qualified Data.Maybe as Maybe+import Data.Maybe (fromMaybe)+import qualified Data.Set as Set+import qualified System.IO.Unsafe as Unsafe++import qualified GHC.Hs as Hs+import qualified GHC.Types.SrcLoc as SrcLoc++import qualified FixImports.Parse as Parse+import qualified FixImports.Types as Types++import EL.Test.Global+++test_importRange = do+ let f = fmap Parse.importRange . parse+ rightEqual (f "") (0, 0)+ rightEqual (f "module M where\n") (1, 1)+ rightEqual (f "-- hi\nmodule M where\n") (2, 2)+ rightEqual (f+ "module M (\n\+ \ x, y\n\+ \) where\n\+ \import A\n\+ \import B\n\+ \f = 42\n")+ (3, 5)+ rightEqual (f+ "module M (\n\+ \ x, y\n\+ \) where\n\+ \f = 42\n")+ (3, 3)+ rightEqual (f+ "module M where\n\+ \f = 42\n")+ (1, 1)++test_extractImports = do+ let f = fmap (head . map strip . Parse.extractImports) . parse+ strip imp = imp { Types._importSpan = Types.noSpan }+ rightEqual (f "import \"pkg\" A ()") $ (Types.makeImport "A")+ { Types._importPkgQualifier = Just "pkg"+ , Types._importEntities = Just []+ }+ rightEqual (f "import {-# SOURCE #-} A") $ (Types.makeImport "A")+ { Types._importIsBoot = True }+ rightEqual (f "import A as B hiding (a, b)") $ (Types.makeImport "A")+ { Types._importAs = Just "B"+ , Types._importHiding = True+ , Types._importEntities = Just+ [ Right $ Types.Entity Nothing (Types.Name "a") Nothing+ , Right $ Types.Entity Nothing (Types.Name "b") Nothing+ ]+ }++test_extensions = do+ let f exts = Parse.parse (map (expectRight . parseExtension) exts) "M.hs"+ let extract = fmap (fmap (extractEntities . fst))+ apply1 leftLike (extract $ f [] "import X (pattern A)") "parse error"+ apply1 rightEqual (extract $ f ["PatternSynonyms"] "import X (pattern A)")+ [Right ("pattern", Types.Name "A", "")]+ apply1 rightEqual+ (extract $ f []+ "{-# LANGUAGE PatternSynonyms #-}\nimport X (pattern A)")+ [Right ("pattern", Types.Name "A", "")]++parseExtension :: String -> Either String Types.Extension+parseExtension w = maybe (Left w) Right $ Types.parseExtension w++test_comments = do+ let f = fmap (fmap (map extract . snd)) . Parse.parse [] "M.hs"+ extract (Parse.Comment (Types.SrcSpan x1 y1 x2 y2) cmt) =+ ((x1, y1, x2, y2), cmt)+ apply1 rightEqual (f "module M where\n") []+ apply1 rightEqual (f "-- above\nimport X -- right\n")+ [((0, 1, 0, 9), "-- above"), ((1, 10, 1, 18), "-- right")]+ apply1 rightEqual (f "-- | above\nimport X {- right -}\n")+ [((0, 1, 0, 11), "-- | above"), ((1, 10, 1, 21), "{- right -}")]+ apply1 rightEqual (f "-- *** section\nimport X {- ^ right -}\n")+ [((0, 1, 0, 15), "-- *** section"), ((1, 10, 1, 23), "{- ^ right -}")]++test_extractEntity = do+ let f = fmap extractEntities . parse+ extractEntities = map (fmap extractEntity) . fromMaybe []+ . Types._importEntities . head . Parse.extractImports+ extractEntity (Types.Entity qual name list) =+ (fromMaybe "" qual, name, fromMaybe "" list)+ let n = Types.Name+ rightEqual (f "import X") []+ rightEqual (f "import X (a, b)")+ [Right ("", n "a", ""), Right ("", n "b", "")]+ rightEqual (f "import X (type A)") [Right ("type", n "A", "")]++ rightEqual (f "import X (A)") [Right ("", n "A", "")]+ rightEqual (f "import X (A(..))") [Right ("", n "A", "(..)")]+ rightEqual (f "import X (A())") [Right ("", n "A", "()")]+ rightEqual (f "import X (A(b, c))") [Right ("", n "A", "(b, c)")]+ rightEqual (f "import X (A(B))") [Right ("", n "A", "(B)")]+ rightEqual (f "import X ((*))") [Right ("", Types.Operator "*", "")]+++extractEntities = map (fmap extractEntity) . fromMaybe []+ . Types._importEntities . head . Parse.extractImports+ where+ extractEntity (Types.Entity qual name list) =+ (fromMaybe "" qual, name, fromMaybe "" list)++test_qualifications = do+ let f = fmap (Set.toList . Parse.qualifications) . parse+ rightEqual (f "f = x") []+ rightEqual (f "f = A.x") ["A"]+ rightEqual (f "f = A.B.x D.y") ["A.B", "D"]+ rightEqual (f "f :: A.X -> B.Y") ["A", "B"]+ rightEqual (f "f = x A.</> y") ["A"]+ rightEqual (f "f = (A.</>) x y") ["A"]+ rightEqual (f "f = g @A.B @C.D") ["A", "C"]++test_unqualifiedValues = do+ let f = fmap (map unname . Set.toList . Parse.unqualifiedValues) . parse+ unname (Types.Name s) = s+ unname (Types.Operator s) = "(" <> s <> ")"+ -- Don't pick up imports and exports.+ rightEqual (f "module A (b) where\nimport C (d)\n") []+ -- Don't pick up function lhs.+ rightEqual (f "f x = 10") []+ rightEqual (f "f x = y") ["y"]+ rightEqual (f "x = y") ["y"]+ -- TODO I'd rather just "pat", but I'd need to dig deeper into+ -- the pattern AST which is complicated.+ rightEqual (f "f | x <- pat = 10") ["pat", "x"]+ rightEqual (f "f = 10 * 20") ["(*)"]+ -- instance declarations+ rightEqual (f "instance A B where f = x") ["x"]++test_unqualifiedTypes = do+ let f = fmap (map unname . Set.toList . Parse.unqualifiedTypes) . parse+ unname (Types.Name s) = s+ unname (Types.Operator s) = "(" <> s <> ")"+ -- Also look in type signatures.+ rightEqual (f "f :: A -> B C") ["A", "B", "C"]+ rightEqual (f "instance A b where f = x") ["A"]+ rightEqual (f "instance A b where f = x") ["A"]+ -- But not type declarations.+ rightEqual (f "data A = B c | D e deriving (Z)") ["Z"]+ rightEqual (f "class A where f :: A") ["A"]++apply1 :: Monad m => (a -> b -> m c) -> m a -> b -> m c+apply1 f ma b = do+ a <- ma+ f a b++parse :: String -> Either String Parse.Module+parse = Unsafe.unsafePerformIO . fmap (fmap fst) . Parse.parse [] "M.hs"
+ src/FixImports/PkgCache.hs view
@@ -0,0 +1,53 @@+{-# LANGUAGE OverloadedStrings #-}+module FixImports.PkgCache (+ load, UnitId, PkgName, ModuleName+) where+import Data.Maybe (mapMaybe)+import Data.Set (Set)+import Data.Text (Text)+import qualified Data.Text as Text+import System.FilePath ((</>))+import qualified Data.Set as Set++import qualified Distribution.InstalledPackageInfo as IPI+import qualified Distribution.ModuleName as ModuleName+import qualified Distribution.Types.PackageId as PackageId+import qualified Distribution.Types.PackageName as PackageName+import qualified Distribution.Types.UnitId as UnitId+import qualified GHC.Unit.Database as Database+++-- t_pkgs = Set.fromList . map fst <$> loadCache cache+-- t_load = load (Set.fromList ["base-4.16.4.0"])+-- [cache, global]+-- cache = "/Users/elaforge/.cabal/store/ghc-9.2.5/package.db"+-- global = "/Users/elaforge/.ghcup/ghc/9.2.5/lib/ghc-9.2.5/lib/package.conf.d"++type UnitId = Text+type PkgName = Text+type ModuleName = String++load :: Set UnitId -> [FilePath] -> IO [(PkgName, [ModuleName])]+load wanted pkgDbs = do+ pkgs <- concat <$> mapM (\dir -> loadCache (dir </> "package.cache"))+ pkgDbs+ pure $ map snd $ filter ((`Set.member` wanted) . fst) pkgs++loadCache :: FilePath -> IO [(UnitId, (PkgName, [ModuleName]))]+loadCache cachePath = do+ (pkgs, _) <- Database.readPackageDbForGhcPkg cachePath+ Database.DbOpenReadOnly+ pure $ mapMaybe extract pkgs++extract :: IPI.InstalledPackageInfo -> Maybe (UnitId, (PkgName, [ModuleName]))+extract pkg+ | not (IPI.exposed pkg) = Nothing+ | otherwise = Just+ ( Text.pack $ UnitId.unUnitId $ IPI.installedUnitId pkg+ , ( pkgName (IPI.sourcePackageId pkg)+ , map (modString . IPI.exposedName) $ IPI.exposedModules pkg+ )+ )+ where+ modString = map (\c -> if c == '/' then '.' else c) . ModuleName.toFilePath+ pkgName = Text.pack . PackageName.unPackageName . PackageId.pkgName
+ src/FixImports/Types.hs view
@@ -0,0 +1,167 @@+{-# LANGUAGE GeneralizedNewtypeDeriving #-}+{-# LANGUAGE PackageImports #-}+module FixImports.Types (+ module FixImports.Types+ , module GHC.LanguageExtensions.Type+) where+import qualified Control.DeepSeq as DeepSeq+import Control.DeepSeq (deepseq)+import qualified Data.Either as Either+import Data.Maybe (fromMaybe)+import qualified Data.Set as Set+import qualified Data.String as String+import qualified System.FilePath as FilePath++import "ghc-lib-parser" GHC.LanguageExtensions.Type (Extension(..))+import qualified "ghc-lib-parser-ex" Language.Haskell.GhclibParserEx.GHC.Driver.Session+++type Error = String++parseExtension :: String -> Maybe Extension+parseExtension =+ Language.Haskell.GhclibParserEx.GHC.Driver.Session.readExtension++-- * ImportLine++data ImportLine = ImportLine {+ importDecl :: !Import+ , importComments :: ![Comment]+ , importSource :: !Source+ } deriving (Show)++instance DeepSeq.NFData ImportLine where+ rnf (ImportLine decl cmts source) =+ decl `seq` cmts `deepseq` source `deepseq` ()++-- | Where did this import come from?+data Source = Local | Package deriving (Eq, Show)++instance DeepSeq.NFData Source where+ rnf _ = ()++-- | A Comment is associated with a particular import line.+data Comment = Comment !CmtPos !String deriving (Show)+data CmtPos = CmtAbove | CmtRight deriving (Show)++instance DeepSeq.NFData Comment where+ rnf (Comment a b) = a `seq` b `seq` ()++-- * Import++data Import = Import {+ _importName :: !ModuleName+ , _importPkgQualifier :: !(Maybe String)+ -- | SOURCE pragma? There is also ideclSourceSrc, but it looks like it's+ -- just to preserve the exact case or British-ness of the pragma, so I can+ -- discard it.+ , _importIsBoot :: !Bool+ , _importSafe :: !Bool -- ^ safe import+ , _importQualified :: !Bool -- ^ qualified+ , _importAs :: !(Maybe Qualification) -- ^ import as+ , _importHiding :: !Bool -- ^ import list is hiding+ , _importEntities :: !(Maybe [Either Error Entity])+ , _importSpan :: !SrcSpan+ } deriving (Eq, Ord, Show)++instance DeepSeq.NFData Import where+ rnf imp = _importPkgQualifier `deepseq` _importEntities imp `deepseq` ()++data SrcSpan = SrcSpan { _startLine, _startCol, _endLine, _endCol :: !Int }+ deriving (Eq, Ord, Show)++noSpan :: SrcSpan+noSpan = SrcSpan 0 0 0 0++makeImport :: ModuleName -> Import+makeImport name = Import+ { _importName = name+ , _importPkgQualifier = Nothing+ , _importIsBoot = False+ , _importSafe = False+ , _importQualified = False+ , _importAs = Nothing+ , _importHiding = False+ , _importEntities = Nothing+ , _importSpan = noSpan+ }++-- | Get the qualified name from an Import.+importQualification :: Import -> Qualification+importQualification imp =+ fromMaybe (toQual (_importName imp)) (_importAs imp)+ where toQual (ModuleName m) = Qualification m++setQualification :: Qualification -> Import -> Import+setQualification qual imp = imp+ { _importQualified = True+ , _importAs = if toQual (_importName imp) == qual then Nothing+ else Just qual+ } where toQual (ModuleName m) = Qualification m++-- | @import X hiding (x)@ doesn't count as an unqualified import, at least not+-- the kind that I manage.+importUnqualified :: Import -> Bool+importUnqualified imp =+ not (_importQualified imp) && not (_importHiding imp)++-- | Has an import list, but is empty. This import is a no-op, except for+-- instances.+importEmpty :: Import -> Bool+importEmpty imp = case _importEntities imp of+ Just [] -> True+ _ -> False++-- | If this import has a import list, modify its contents.+importModify :: ([Entity] -> [Entity]) -> Import -> Import+importModify modify imp = case (_importHiding imp, _importEntities imp) of+ (False, Just errEntities) -> imp+ { _importEntities = Just $ map Left errs+ ++ map Right (normalize (modify entities))+ }+ where (errs, entities) = Either.partitionEithers errEntities+ _ -> imp+ where+ -- Keep entities unique and sorted.+ normalize = Set.toList . Set.fromList++-- | An imported entity, e.g. @import X (entity)@.+data Entity = Entity {+ _entityQualifier :: !(Maybe String)+ , _entityVar :: !Name+ , _entityList :: !(Maybe String)+ } deriving (Eq, Ord, Show)++instance DeepSeq.NFData Entity where+ rnf (Entity a b c) = a `deepseq` b `deepseq` c `deepseq` ()++-- | A Qualification is a qualified name minus the actual name. So it should+-- be the tail of a ModuleName.+newtype Qualification = Qualification String+ deriving (Eq, Ord, Show, DeepSeq.NFData, String.IsString)++-- | An unqualified identifier.+data Name = Name !String | Operator !String+ deriving (Eq, Ord, Show)++instance DeepSeq.NFData Name where+ rnf (Name s) = DeepSeq.rnf s+ rnf (Operator s) = DeepSeq.rnf s++showName :: Name -> String+showName (Name s) = s+showName (Operator s) = "(" <> s <> ")"++newtype ModuleName = ModuleName String+ deriving (Eq, Ord, Show, DeepSeq.NFData, String.IsString)++moduleName :: ModuleName -> String+moduleName (ModuleName n) = n++pathToModule :: FilePath -> ModuleName+pathToModule = ModuleName+ . map (\c -> if c == '/' then '.' else c) . FilePath.dropExtension++moduleToPath :: ModuleName -> FilePath+moduleToPath (ModuleName name) =+ map (\c -> if c == '.' then '/' else c) name ++ ".hs"
+ src/FixImports/Util.hs view
@@ -0,0 +1,187 @@+module FixImports.Util where+import Prelude hiding (head)+import qualified Control.Concurrent as Concurrent+import qualified Control.Concurrent.MVar as MVar+import qualified Control.Exception as Exception+import Control.Monad++import qualified Data.Char as Char+import qualified Data.Function as Function+import qualified Data.IntSet as IntSet+import qualified Data.List as List+import qualified Data.List.Split as Split+import qualified Data.Map as Map+import qualified Data.Set as Set+import qualified Data.Text as T+import qualified Data.Text.IO as Text.IO++import qualified System.Directory as Directory+import qualified System.Exit as Exit+import System.FilePath ((</>))+import qualified System.IO as IO+import qualified System.IO.Error as IO.Error+import qualified System.Process as Process+++-- | Copy paste from FastTags.Tag.haskellOpChar.+--+-- From the haskell report:+-- > varsym → ( symbol⟨:⟩ {symbol} )⟨reservedop | dashes⟩+-- > symbol → ascSymbol | uniSymbol⟨special | _ | " | '⟩+-- > uniSymbol → any Unicode symbol or punctuation+haskellOpChar :: Char -> Bool+haskellOpChar c =+ IntSet.member (Char.ord c) opChars+ || (IntSet.notMember (Char.ord c) exceptions+ && isSymbolCharacterCategory (Char.generalCategory c))+ where+ opChars = IntSet.fromList $ map Char.ord "-!#$%&*+./<=>?@^|~:\\"+ exceptions = IntSet.fromList $ map Char.ord "_\"'"++isSymbolCharacterCategory :: Char.GeneralCategory -> Bool+isSymbolCharacterCategory cat = Set.member cat symbolCategories+ where+ symbolCategories :: Set.Set Char.GeneralCategory+ symbolCategories = Set.fromList+ [ Char.ConnectorPunctuation+ , Char.DashPunctuation+ , Char.OtherPunctuation+ , Char.MathSymbol+ , Char.CurrencySymbol+ , Char.ModifierSymbol+ , Char.OtherSymbol+ ]++-- * list++-- | List initial and final element, if any.+unsnoc :: [a] -> Maybe ([a], a)+unsnoc [] = Nothing+unsnoc (x:xs) = Just $ go x xs+ where+ go x [] = ([], x)+ go x (x':xs) = let (pre, post) = go x' xs in (x:pre, post)++-- | Concat a list with 'sep' in between.+join :: [a] -> [[a]] -> [a]+join = List.intercalate++-- | Split 'xs' on 'sep', dropping 'sep' from the result.+split :: (Eq a) => [a] -> [a] -> [[a]]+split = Split.splitOn++-- | Split where the function matches.+splitWith :: (a -> Bool) -> [a] -> [[a]]+splitWith f xs = map reverse (go f xs [])+ where+ go _ [] collect = [collect]+ go f (x:xs) collect+ | f x = collect : go f xs [x]+ | otherwise = go f xs (x:collect)++head :: [a] -> Maybe a+head [] = Nothing+head (x:_) = Just x++sortOn :: (Ord k) => (a -> k) -> [a] -> [a]+sortOn key = List.sortBy (compare `Function.on` key)++groupOn :: (Eq k) => (a -> k) -> [a] -> [[a]]+groupOn key = List.groupBy ((==) `Function.on` key)++minimumOn :: Ord k => (a -> k) -> [a] -> Maybe a+minimumOn _ [] = Nothing+minimumOn key xs = Just (List.foldl1' f xs)+ where f low x = if key x < key low then x else low++-- | Like 'List.partition', but partition by two functions consecutively.+partition2 :: (a -> Bool) -> (a -> Bool) -> [a] -> ([a], [a], [a])+partition2 f1 f2 xs = (as, bs, xs3)+ where+ (as, xs2) = List.partition f1 xs+ (bs, xs3) = List.partition f2 xs2++zipPrev :: [a] -> [(a, a)]+zipPrev xs = zip xs (drop 1 xs)++-- | Modify a list at the first place the predicate matches.+modifyAt :: (a -> Bool) -> (a -> a) -> [a] -> Maybe [a]+modifyAt match modify = go+ where+ go [] = Nothing+ go (x:xs)+ | match x = Just (modify x : xs)+ | otherwise = (x:) <$> go xs++setmap :: (Ord k, Ord a) => [(k, a)] -> Map.Map k (Set.Set a)+setmap = Map.fromAscList+ . map (\gs -> (fst (List.head gs), Set.fromList (map snd gs)))+ . groupOn fst . sortOn fst++multimap :: Ord k => [(k, a)] -> Map.Map k [a]+multimap = Map.fromAscList . map (\gs -> (fst (List.head gs), map snd gs))+ . groupOn fst . sortOn fst++partitionOn :: (a -> Maybe b) -> [a] -> ([b], [a])+partitionOn f = go+ where+ go [] = ([], [])+ go (x:xs) = case f x of+ Just b -> (b:bs, as)+ Nothing -> (bs, x:as)+ where (bs, as) = go xs++uniqueOn :: Ord k => (a -> k) -> [a] -> [a]+uniqueOn key xs = Map.elems $ Map.fromList $ zip (map key xs) xs++-- * control++ifM :: Monad m => m Bool -> m a -> m a -> m a+ifM cond consequent alternative = do+ b <- cond+ if b then consequent else alternative++partitionM :: (Monad m) => (a -> m Bool) -> [a] -> m ([a], [a])+partitionM f = go [] []+ where+ go ts fs [] = return (ts, fs)+ go ts fs (x:xs) = ifM (f x) (go (x:ts) fs xs) (go ts (x:fs) xs)++anyM :: Monad m => (a -> m Bool) -> [a] -> m Bool+anyM _ [] = return False+anyM f (x:xs) = ifM (f x) (return True) (anyM f xs)++-- | If @op@ raised ENOENT, return Nothing.+catchENOENT :: IO a -> IO (Maybe a)+catchENOENT op = Exception.handleJust (guard . IO.Error.isDoesNotExistError)+ (const (return Nothing)) (fmap Just op)++-- * file++listDir :: FilePath -> IO [FilePath]+listDir dir = fmap (map add . filter (not . (`elem` [".", ".."])))+ (Directory.getDirectoryContents dir)+ where add = if dir == "." then id else (dir </>)++-- | Similar to System.Process.readProcessWithExitCode but return Text instead+-- of String.+readProcessWithExitCode :: FilePath -> [String]+ -> IO (Exit.ExitCode, T.Text, T.Text)+readProcessWithExitCode cmd args = do+ (_, Just outh, Just errh, pid) <-+ Process.createProcess (Process.proc cmd args)+ { Process.std_out = Process.CreatePipe+ , Process.std_err = Process.CreatePipe+ }+ outMVar <- MVar.newEmptyMVar+ errMVar <- MVar.newEmptyMVar+ void $ Concurrent.forkIO $+ MVar.putMVar outMVar =<< Text.IO.hGetContents outh+ void $ Concurrent.forkIO $+ MVar.putMVar errMVar =<< Text.IO.hGetContents errh+ out <- MVar.takeMVar outMVar+ err <- MVar.takeMVar errMVar+ IO.hClose outh+ IO.hClose errh+ ex <- Process.waitForProcess pid+ return (ex, out, err)
− src/FixImports_test.hs
@@ -1,213 +0,0 @@-{-# LANGUAGE DisambiguateRecordFields #-}-{-# LANGUAGE OverloadedStrings #-}-module FixImports_test where-import Control.Monad (unless, void)-import qualified Control.Monad.Identity as Identity-import qualified Control.Monad.State.Strict as State-import Data.Bifunctor (bimap)-import qualified Data.List as List-import qualified Data.Map as Map-import qualified Data.Maybe as Maybe-import qualified Data.Set as Set-import qualified Data.Text as Text-import qualified Data.Time.Clock.POSIX as Clock.POSIX--import qualified Language.Haskell.Exts as Haskell-import qualified System.FilePath as FilePath--import qualified Config-import qualified FixImports-import qualified Index-import qualified Types--import EL.Test.Global---test_simple = do- let run config = fmap FixImports.resultImports- . fixModule index ["C.hs"] (mkConfig config) "A.hs"- index = Index.makeIndex- [ ("pkg", ["A.B"])- , ("zpkg", ["Z"])- ]- equal (run "" "x = B.c") $ Right "import qualified A.B as B\n"- equal (run "" "x = A.B.c") $ Right "import qualified A.B\n"- leftLike (run "" "x = Q.c") "not found: Q"- -- Remove unused.- equal (run "" "import qualified A.B as B\n\nx = y") $ Right ""- -- Unless it's unqualified.- equal (run "" "import A.B as B\n\nx = y") $ Right- "import A.B as B\n"-- -- Local goes below package.- equal (run "" "x = (B.a, C.a, Z.a)") $ Right- "import qualified A.B as B\n\- \import qualified Z\n\- \\n\- \import qualified C\n"-- -- Don't mess with imports I don't manage.- equal (run "" "import A.B hiding (mod)\n") $- Right "import A.B hiding (mod)\n"- equal (run "" "import A.B\n") $- Right "import A.B\n"--test_qualifyAs = do- let run config files = fmap eResult . fixModule index files config "A.hs"- config = mkConfig "qualify-as: Data.Text.Lazy as DTL"- index = Index.makeIndex [("text", ["Data.Text.Lazy"])]- equal (run config [] "x = DTL.y") $ Right- ( ["Data.Text.Lazy"]- , []- , "import qualified Data.Text.Lazy as DTL\n"- )- equal (run config [] "import qualified Data.Text.Lazy as DTL\n") $ Right- ( []- , ["Data.Text.Lazy"]- , ""- )-- -- qualifyAs aliases are prioritized in the same way as others, so- -- the local module wins:- equal (run config ["DTL.hs"] "x = DTL.y") $- Right (["DTL"], [], "import qualified DTL\n")-- -- Unless explicitly suppressed:- let config2 = mkConfig $ Text.unlines- [ "qualify-as: Data.Text.Lazy as DTL"- , "prio-module-high: Data.Text.Lazy"- ]- equal (run config2 ["DTL.hs"] "x = DTL.y") $ Right- ( ["Data.Text.Lazy"]- , []- , "import qualified Data.Text.Lazy as DTL\n"- )--test_unqualified = do- let run config = fmap eResult- . fixModule index ["C.hs"] (mkConfig config) "A.hs"- index = Index.makeIndex- [ ("pkg", ["A.B"])- , ("zpkg", ["Z"])- ]- equal (run "unqualified: A.B (c)" "x = (c, c)") $ Right- ( ["A.B"]- , []- , "import A.B (c)\n"- )-- -- Modify an existing import.- equal (run "unqualified: A.B (c)" "import A.B (a, z)\nx = (c, c)") $ Right- ( []- , []- , "import A.B (a, c, z)\n"- )- equal (run "unqualified: A.B (a, c)" "import A.B (a, c)\nx = a") $ Right- ( []- , []- , "import A.B (a)\n"- )- -- Don't accumulate duplicates.- equal (run "unqualified: A.B (c)" "import A.B (c)\nx = c") $- Right ([], [], "import A.B (c)\n")- equal (run "unqualified: A.B (C)" "import A.B (C)\nx :: C") $- Right ([], [], "import A.B (C)\n")-- -- Don't manage it if it's not mine.- equal (run "unqualified: A.B (c)" "import A.B (d)") $ Right- ([], [], "import A.B (d)\n")-- -- local still goes below package- equal (run "unqualified: C (a)" "import A.B\nimport Z\nx = a") $ Right- ( ["C"]- , []- , "import A.B\n\- \import Z\n\- \\n\- \import C (a)\n"- )-- -- Don't import when it's an assignee.- equal (run "unqualified: A.B (c)" "c = x") $ Right ([], [], "")- equal (run "unqualified: A.B ((</>))" "x = a </> b") $- Right (["A.B"], [], "import A.B ((</>))\n")-- -- Removed unused.- equal (run "unqualified: A.B (c)" "import A.B (c)\nx = x\n") $- Right ([], ["A.B"], "")- -- But not if it's a spec-less import.- equal (run "unqualified: A.B (c)" "import A.B\nx = x\n") $- Right ([], [], "import A.B\n")--eResult :: FixImports.Result -> ([Types.ModuleName], [Types.ModuleName], String)-eResult r =- ( Set.toList (FixImports.resultAdded r)- , Set.toList (FixImports.resultRemoved r)- , FixImports.resultImports r- )--fixModule :: Index.Index -> [FilePath]- -> Config.Config -> FilePath -> String -> Either String FixImports.Result-fixModule index files config modulePath source =- case FixImports.parse (Config._language config) modulePath source of- Haskell.ParseFailed srcloc err ->- Left $ Haskell.prettyPrint srcloc ++ ": " ++ err- Haskell.ParseOk (mod, cmts) ->- fst $ Identity.runIdentity $ flip State.runStateT [] $- FixImports.fixImports (pureFilesystem files) config index- modulePath mod cmts---- | The ./ stuff is tricky, this is probably still wrong.-pureFilesystem :: [FilePath] -> FixImports.Filesystem Identity.Identity-pureFilesystem files = FixImports.Filesystem- { _listDir = \dir -> return- . Maybe.fromMaybe ([], []) . (`Map.lookup` tree) $ dir- , _doesFileExist = \fn -> return . (`elem` files) . normalize $ fn- , _metric = \_ _ ->- return (Clock.POSIX.posixSecondsToUTCTime 0, Text.pack "metric")- }- where- tree = filesToTree (map ("./"++) files)- normalize ('.':'/':fn) = fn- normalize fn = fn---- group by first element, then second, etc.-filesToTree :: [FilePath] -> Map.Map FilePath ([FilePath], [FilePath])- -- ^ (dirs, files)-filesToTree = Map.fromList- . map (bimap (List.dropWhileEnd (=='/')) separate) . groupFst- . concatMap prefixes- where- separate [""] = ([], [])- separate subs = (unique $ map (takeWhile (/='/')) dirs, files)- where (dirs, files) = List.partition ('/' `elem`) subs--prefixes :: FilePath -> [(FilePath, FilePath)]-prefixes = map (bimap concat concat) . drop 1- . (\xs -> zip (List.inits xs) (List.tails xs)) . FilePath.splitPath--mkConfig :: Text.Text -> Config.Config-mkConfig content- | null errs = config { Config._includes = ["."] }- | otherwise = error $ "parsing " <> show content <> ": "- <> unlines (map Text.unpack errs)- where (config, errs) = Config.parse content---- * util--type NonNull a = [a]---- | Similar to 'keyedGroupSort', but key on the fst element, and strip the--- key out of the groups.-groupFst :: Ord a => [(a, b)] -> [(a, NonNull b)]-groupFst xs = [(key, map snd group) | (key, group) <- keyedGroupSort fst xs]---- | Group the unsorted list into @(key x, xs)@ where all @xs@ compare equal--- after @key@ is applied to them.-keyedGroupSort :: Ord key => (a -> key) -> [a] -> [(key, NonNull a)]- -- ^ Sorted by key. The NonNull group is in the same order as the input.-keyedGroupSort key = Map.toAscList . foldr go Map.empty- where go x = Map.alter (Just . maybe [x] (x:)) (key x)--unique :: Ord a => [a] -> [a]-unique = Set.toList . Set.fromList
− src/Index.hs
@@ -1,95 +0,0 @@-{-# LANGUAGE OverloadedStrings #-}--- | Maintain the index from Qualification to the full module from the package--- db that this Qualification probably intends.-module Index (Index, Package, empty, load, makeIndex, parseSections) where-import Prelude hiding (mod)-import Control.Monad-import qualified Data.Either as Either-import qualified Data.List as List-import qualified Data.Map as Map-import qualified Data.Text as T-import qualified Data.Maybe as Maybe-import Data.Text (Text)--import qualified System.IO as IO--import qualified Types-import qualified Util----- | Map from tails of the each module in the package db to its module name.--- So @List@ and @Data.List@ will map to @Data.List@. Modules from a set--- of core packages, like base and containers, will take priority, so even if--- there's a package with @Some.Obscure.List@, @List@ will still map to--- @Data.List@.-type Index = Map.Map Types.Qualification [(Package, Types.ModuleName)]---- | Package name without the version.-type Package = String--empty :: Index-empty = Map.empty--load :: IO Index-load = build -- TODO load cache?--build :: IO Index-build = do- (_, out, err) <- Util.readProcessWithExitCode "ghc-pkg"- ["field", "*", "name,exposed,exposed-modules"]- unless (T.null err) $- IO.hPutStrLn IO.stderr $ "stderr from ghc-pkg: " ++ T.unpack err- let (errors, index) = parseDump out- unless (null errors) $- IO.hPutStrLn IO.stderr $ "errors parsing ghc-pkg output: "- ++ List.intercalate ", " errors- return index--makeIndex :: [(Text, [Types.ModuleName])] -- ^ [(package, modules)]- -> Index-makeIndex packages = Map.fromListWith (++)- [ (qual, [(T.unpack package, mod)])- | (package, modules) <- packages- , mod <- modules- , qual <- moduleQualifications mod- ]--parseDump :: Text -> ([String], Index)-parseDump text = (errors, makeIndex packages)- where- (errors, packages) = Either.partitionEithers $- extractSections (parseSections text)--extractSections :: [(Text, [Text])]- -> [Either String (Text, [Types.ModuleName])]-extractSections = Maybe.mapMaybe extract . Util.splitWith ((=="name") . fst)- where- extract [ ("name", [name])- , ("exposed", [exposed])- , ("exposed-modules", modules)- ]- | exposed /= "True" = Nothing- | otherwise = Just $- Right (name, map (Types.ModuleName . T.unpack) modules)- -- It may be missing exposed-modules, but that means I don't need it.- extract _ = Nothing---- | Take a module name to all its possible qualifications, i.e. its list--- of suffixes.-moduleQualifications :: Types.ModuleName -> [Types.Qualification]-moduleQualifications = map (Types.Qualification . Util.join ".")- . filter (not . null) . List.tails . Util.split "." . Types.moduleName--parseSections :: Text -> [(Text, [Text])] -- ^ [(section_name, words)]-parseSections = List.unfoldr parseSection . stripComments . T.lines--stripComments :: [Text] -> [Text]-stripComments = filter (not . T.null) . map (T.stripEnd . fst . T.breakOn "--")--parseSection :: [Text] -> Maybe ((Text, [Text]), [Text])-parseSection [] = Nothing-parseSection (x:xs) =- Just ((tag, concatMap T.words (T.drop 1 rest : pre)), post)- where- (tag, rest) = T.break (==':') x- (pre, post) = span (" " `T.isPrefixOf`) xs
− src/Main.hs
@@ -1,122 +0,0 @@--- | Main file for FixImports that uses the default formatting. It reads--- a config file from the current directory.------ More documentation in "FixImports".-{-# LANGUAGE DisambiguateRecordFields #-}-{-# LANGUAGE OverloadedStrings #-}-{-# LANGUAGE ScopedTypeVariables #-}-module Main where-import qualified Control.Exception as Exception-import Control.Monad (when)-import qualified Data.Set as Set-import qualified Data.Text as Text-import qualified Data.Text.IO as Text.IO-import qualified Data.Version as Version--import qualified System.Console.GetOpt as GetOpt-import qualified System.Environment as Environment-import qualified System.Exit as Exit-import qualified System.IO as IO--import qualified Config-import qualified FixImports-import qualified Paths_fix_imports-import qualified Types-import qualified Util---main :: IO ()-main = do- -- I need the module path to search for modules relative to it first. I- -- could figure it out from the parsed module name, but a main module may- -- not have a name.- (modulePath, flags) <- parseArgs =<< Environment.getArgs- let configFile = if null fns then ".fix-imports" else last fns- where fns = [fn | Config fn <- flags]- (config, errors) <- readConfig configFile- if null errors- then mainConfig config flags modulePath- else do- Text.IO.hPutStrLn IO.stderr $ Text.unlines errors- Exit.exitFailure--readConfig :: FilePath -> IO (Config.Config, [Text.Text])-readConfig = fmap (maybe (Config.empty, []) Config.parse)- . Util.catchENOENT . Text.IO.readFile--mainConfig :: Config.Config -> [Flag] -> FilePath -> IO ()-mainConfig config flags modulePath = do- let (verbose, debug, includes) = extractFlags flags- source <- IO.getContents- config <- return $ config- { Config._includes = includes ++ Config._includes config- , Config._debug = debug- }- (fixed, logs) <- FixImports.fixModule config modulePath source- `Exception.catch` \(exc :: Exception.SomeException) ->- return (Left $ "exception: " ++ show exc, [])- case fixed of- Left err -> do- if Edit `elem` flags then putStrLn "0,0" else putStr source- when debug $ mapM_ (Text.IO.hPutStrLn IO.stderr) logs- IO.hPutStrLn IO.stderr $ "error: " ++ err- Exit.exitFailure- Right (FixImports.Result range imports added removed metrics) -> do- if Edit `elem` flags- then do- putStrLn $ show (fst range) <> "," <> show (snd range)- putStr imports- else putStr $ FixImports.substituteImports imports range source- let names = Util.join ", " . map Types.moduleName . Set.toList- (addedMsg, removedMsg) = (names added, names removed)- mDone <- FixImports.metric metrics "done"- when debug $ mapM_ (Text.IO.hPutStrLn IO.stderr) logs- Config.debug config $ Text.stripEnd $- FixImports.showMetrics (mDone : metrics)- when (verbose && not (null addedMsg) || not (null removedMsg)) $- IO.hPutStrLn IO.stderr $ Util.join "; " $ filter (not . null)- [ if null addedMsg then "" else "added: " ++ addedMsg- , if null removedMsg then "" else "removed: " ++ removedMsg- ]- Exit.exitSuccess--data Flag = Config FilePath | Debug | Edit | Include String | Verbose- deriving (Eq, Show)--options :: [GetOpt.OptDescr Flag]-options =- [ GetOpt.Option ['c'] ["config"] (GetOpt.ReqArg Config "path")- "path to config file, defaults to .fix-imports"- , GetOpt.Option [] ["edit"] (GetOpt.NoArg Edit)- "print delete range and new import block, rather than the whole file"- , GetOpt.Option [] ["debug"] (GetOpt.NoArg Debug)- "print debugging info on stderr"- , GetOpt.Option ['i'] [] (GetOpt.ReqArg Include "path")- "add to module include path"- , GetOpt.Option ['v'] [] (GetOpt.NoArg Verbose)- "print added and removed modules on stderr"- ]--parseArgs :: [String] -> IO (String, [Flag])-parseArgs args = case GetOpt.getOpt GetOpt.Permute options args of- (flags, [modulePath], []) -> return (modulePath, flags)- (_, [], errs) -> usage $ concat errs- _ -> usage "too many args"--extractFlags :: [Flag] -> (Bool, Bool, [FilePath])-extractFlags flags =- ( Verbose `elem` flags- , Debug `elem` flags- , "." : [p | Include p <- flags]- )- -- Includes always have the current directory first.--usage :: String -> IO a-usage msg = do- name <- Environment.getProgName- IO.hPutStr IO.stderr $- GetOpt.usageInfo (msg ++ "\n" ++ name ++ " Module.hs <Module.hs")- options- IO.hPutStrLn IO.stderr $- "version: " ++ Version.showVersion Paths_fix_imports.version- Exit.exitFailure
− src/Types.hs
@@ -1,77 +0,0 @@-{-# LANGUAGE GeneralizedNewtypeDeriving #-}-module Types where-import qualified Control.DeepSeq as DeepSeq-import Control.DeepSeq (deepseq)-import qualified Data.String as String-import qualified Language.Haskell.Exts as Haskell-import qualified System.FilePath as FilePath---instance DeepSeq.NFData Haskell.Comment where- rnf (Haskell.Comment bool srcspan str) =- bool `seq` srcspan `seq` str `deepseq` ()--data ImportLine = ImportLine {- importDecl :: !ImportDecl- , importComments :: ![Comment]- , importSource :: !Source- } deriving (Show)--instance DeepSeq.NFData ImportLine where- rnf (ImportLine decl cmts source) =- decl `seq` cmts `deepseq` source `seq` ()---- | Where did this import come from?-data Source = Local | Package deriving (Eq, Show)---- | A Comment is associated with a particular import line.-data Comment = Comment !CmtPos !String deriving (Show)-data CmtPos = CmtAbove | CmtRight deriving (Show)--instance DeepSeq.NFData Comment where- rnf (Comment a b) = a `seq` b `seq` ()---- | A parsed import line.-type ImportDecl = Haskell.ImportDecl Haskell.SrcSpanInfo-type Module = Haskell.Module Haskell.SrcSpanInfo---- | A Qualification is a qualified name minus the actual name. So it should--- be the tail of a ModuleName.-newtype Qualification = Qualification String- deriving (Eq, Ord, Show, String.IsString)---- | An unqualified identifier.-type Name = Haskell.Name Haskell.SrcSpanInfo--newtype ModuleName = ModuleName String- deriving (Eq, Ord, Show, DeepSeq.NFData, String.IsString)--moduleName :: ModuleName -> String-moduleName (ModuleName n) = n--pathToModule :: FilePath -> ModuleName-pathToModule = ModuleName- . map (\c -> if c == '/' then '.' else c) . FilePath.dropExtension--moduleToPath :: ModuleName -> FilePath-moduleToPath (ModuleName name) =- map (\c -> if c == '.' then '/' else c) name ++ ".hs"---- | Get the qualified name from an import.-importDeclQualification :: ImportDecl -> Qualification-importDeclQualification decl = moduleToQualification $- maybe (Haskell.importModule decl) id (Haskell.importAs decl)---- | Extract the ModuleName from an ImportDecl.-importDeclModule :: ImportDecl -> ModuleName-importDeclModule imp = case Haskell.importModule imp of- Haskell.ModuleName _ s -> ModuleName s--importModule :: ImportLine -> ModuleName-importModule = importDeclModule . importDecl---- | The parser represents the \'as\' part of an import as a ModuleName even--- though it's actually a Qualification.-moduleToQualification :: Haskell.ModuleName Haskell.SrcSpanInfo- -> Qualification-moduleToQualification (Haskell.ModuleName _ s) = Qualification s
− src/Util.hs
@@ -1,180 +0,0 @@-module Util where-import Prelude hiding (head)-import qualified Control.Concurrent as Concurrent-import qualified Control.Concurrent.MVar as MVar-import qualified Control.Exception as Exception-import Control.Monad--import qualified Data.Char as Char-import qualified Data.Function as Function-import qualified Data.IntSet as IntSet-import qualified Data.List as List-import qualified Data.List.Split as Split-import qualified Data.Map as Map-import qualified Data.Set as Set-import qualified Data.Text as T-import qualified Data.Text.IO as Text.IO--import qualified System.Directory as Directory-import qualified System.Exit as Exit-import System.FilePath ((</>))-import qualified System.IO as IO-import qualified System.IO.Error as IO.Error-import qualified System.Process as Process----- | Copy paste from FastTags.Tag.haskellOpChar.------ From the haskell report:--- > varsym → ( symbol⟨:⟩ {symbol} )⟨reservedop | dashes⟩--- > symbol → ascSymbol | uniSymbol⟨special | _ | " | '⟩--- > uniSymbol → any Unicode symbol or punctuation-haskellOpChar :: Char -> Bool-haskellOpChar c =- IntSet.member (Char.ord c) opChars- || (IntSet.notMember (Char.ord c) exceptions- && isSymbolCharacterCategory (Char.generalCategory c))- where- opChars = IntSet.fromList $ map Char.ord "-!#$%&*+./<=>?@^|~:\\"- exceptions = IntSet.fromList $ map Char.ord "_\"'"--isSymbolCharacterCategory :: Char.GeneralCategory -> Bool-isSymbolCharacterCategory cat = Set.member cat symbolCategories- where- symbolCategories :: Set.Set Char.GeneralCategory- symbolCategories = Set.fromList- [ Char.ConnectorPunctuation- , Char.DashPunctuation- , Char.OtherPunctuation- , Char.MathSymbol- , Char.CurrencySymbol- , Char.ModifierSymbol- , Char.OtherSymbol- ]---- * list---- | List initial and final element, if any.-unsnoc :: [a] -> Maybe ([a], a)-unsnoc [] = Nothing-unsnoc (x:xs) = Just $ go x xs- where- go x [] = ([], x)- go x (x':xs) = let (pre, post) = go x' xs in (x:pre, post)---- | Concat a list with 'sep' in between.-join :: [a] -> [[a]] -> [a]-join = List.intercalate---- | Split 'xs' on 'sep', dropping 'sep' from the result.-split :: (Eq a) => [a] -> [a] -> [[a]]-split = Split.splitOn---- | Split where the function matches.-splitWith :: (a -> Bool) -> [a] -> [[a]]-splitWith f xs = map reverse (go f xs [])- where- go _ [] collect = [collect]- go f (x:xs) collect- | f x = collect : go f xs [x]- | otherwise = go f xs (x:collect)--head :: [a] -> Maybe a-head [] = Nothing-head (x:_) = Just x--sortOn :: (Ord k) => (a -> k) -> [a] -> [a]-sortOn key = List.sortBy (compare `Function.on` key)--groupOn :: (Eq k) => (a -> k) -> [a] -> [[a]]-groupOn key = List.groupBy ((==) `Function.on` key)--minimumOn :: Ord k => (a -> k) -> [a] -> Maybe a-minimumOn _ [] = Nothing-minimumOn key xs = Just (List.foldl1' f xs)- where f low x = if key x < key low then x else low---- | Like 'List.partition', but partition by two functions consecutively.-partition2 :: (a -> Bool) -> (a -> Bool) -> [a] -> ([a], [a], [a])-partition2 f1 f2 xs = (as, bs, xs3)- where- (as, xs2) = List.partition f1 xs- (bs, xs3) = List.partition f2 xs2--zipPrev :: [a] -> [(a, a)]-zipPrev xs = zip xs (drop 1 xs)---- | Modify a list at the first place the predicate matches.-modifyAt :: (a -> Bool) -> (a -> a) -> [a] -> Maybe [a]-modifyAt match modify = go- where- go [] = Nothing- go (x:xs)- | match x = Just (modify x : xs)- | otherwise = (x:) <$> go xs--multimap :: Ord k => [(k, a)] -> Map.Map k [a]-multimap = Map.fromAscList . map (\gs -> (fst (List.head gs), map snd gs))- . groupOn fst . sortOn fst--partitionOn :: (a -> Maybe b) -> [a] -> ([b], [a])-partitionOn f = go- where- go [] = ([], [])- go (x:xs) = case f x of- Just b -> (b:bs, as)- Nothing -> (bs, x:as)- where (bs, as) = go xs---- * control--ifM :: Monad m => m Bool -> m a -> m a -> m a-ifM cond consequent alternative = do- b <- cond- if b then consequent else alternative--partitionM :: (Monad m) => (a -> m Bool) -> [a] -> m ([a], [a])-partitionM f = go [] []- where- go ts fs [] = return (ts, fs)- go ts fs (x:xs) = ifM (f x) (go (x:ts) fs xs) (go ts (x:fs) xs)--anyM :: Monad m => (a -> m Bool) -> [a] -> m Bool-anyM _ [] = return False-anyM f (x:xs) = ifM (f x) (return True) (anyM f xs)---- | If @op@ raised ENOENT, return Nothing.-catchENOENT :: IO a -> IO (Maybe a)-catchENOENT op = Exception.handleJust (guard . IO.Error.isDoesNotExistError)- (const (return Nothing)) (fmap Just op)---- * file--listDir :: FilePath -> IO [FilePath]-listDir dir = fmap (map add . filter (not . (`elem` [".", ".."])))- (Directory.getDirectoryContents dir)- where add = if dir == "." then id else (dir </>)---- | Similar to System.Process.readProcessWithExitCode but return Text instead--- of String.-readProcessWithExitCode :: FilePath -> [String]- -> IO (Exit.ExitCode, T.Text, T.Text)-readProcessWithExitCode cmd args = do- (_, Just outh, Just errh, pid) <-- Process.createProcess (Process.proc cmd args)- { Process.std_out = Process.CreatePipe- , Process.std_err = Process.CreatePipe- }- outMVar <- MVar.newEmptyMVar- errMVar <- MVar.newEmptyMVar- void $ Concurrent.forkIO $- MVar.putMVar outMVar =<< Text.IO.hGetContents outh- void $ Concurrent.forkIO $- MVar.putMVar errMVar =<< Text.IO.hGetContents errh- out <- MVar.takeMVar outMVar- err <- MVar.takeMVar errMVar- IO.hClose outh- IO.hClose errh- ex <- Process.waitForProcess pid- return (ex, out, err)-