├── Setup.hs ├── .gitignore ├── LICENSE ├── stack.yaml ├── hackage-diff.cabal ├── README.md └── Main.hs /Setup.hs: -------------------------------------------------------------------------------- 1 | import Distribution.Simple 2 | main = defaultMain 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .cabal-sandbox/ 2 | cabal.sandbox.config 3 | dist/ 4 | .stack-work/ 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | 3 | Copyright (C) 2014 Tim C. Schroeder 4 | 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 9 | 10 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 11 | 12 | -------------------------------------------------------------------------------- /stack.yaml: -------------------------------------------------------------------------------- 1 | # For more information, see: https://github.com/commercialhaskell/stack/blob/release/doc/yaml_configuration.md 2 | 3 | # Specifies the GHC version and set of packages available (e.g., lts-3.5, nightly-2015-09-21, ghc-7.10.2) 4 | resolver: lts-11.7 5 | 6 | # Local packages, usually specified by relative directory name 7 | packages: 8 | - '.' 9 | 10 | # Packages to be pulled from upstream that are not in the resolver (e.g., acme-missiles-0.3) 11 | extra-deps: [] 12 | 13 | # Override default flag values for local packages and extra-deps 14 | flags: {} 15 | 16 | # Extra package databases containing global packages 17 | extra-package-dbs: [] 18 | 19 | # Control whether we use the GHC we find on the path 20 | # system-ghc: true 21 | 22 | # Require a specific version of stack, using version ranges 23 | # require-stack-version: -any # Default 24 | # require-stack-version: >= 1.0.0 25 | 26 | # Override the architecture used by stack, especially useful on Windows 27 | # arch: i386 28 | # arch: x86_64 29 | 30 | # Extra directories used by stack for building 31 | # extra-include-dirs: [/path/to/dir] 32 | # extra-lib-dirs: [/path/to/dir] 33 | -------------------------------------------------------------------------------- /hackage-diff.cabal: -------------------------------------------------------------------------------- 1 | -- Initial hackage-diff.cabal generated by cabal init. For further 2 | -- documentation, see http://haskell.org/cabal/users-guide/ 3 | 4 | name: hackage-diff 5 | version: 0.1.0.1 6 | synopsis: Compare the public API of different versions of a Hackage library 7 | description: Please see for a user's manual. 8 | . 9 | Sample output 10 | . 11 | > Downloading Hoogle DBs... 12 | > Parsing Hoogle DBs... 13 | > Comparing Hoogle DBs... 14 | > 15 | > --- Diff for | 0.2 → 0.3.5.2 | --- 16 | > 17 | > + Data.Serialize.IEEE754 18 | > + getFloat32be :: Get Float 19 | > + getFloat32le :: Get Float 20 | > + getFloat64be :: Get Double 21 | > + getFloat64le :: Get Double 22 | > + putFloat32be :: Float -> Put 23 | > + putFloat32le :: Float -> Put 24 | > + putFloat64be :: Double -> Put 25 | > + putFloat64le :: Double -> Put 26 | > × Data.Serialize 27 | > + instance Serialize a => GSerialize (K1 i a) 28 | > + instance GSerialize a => GSerialize (M1 i c a) 29 | > + instance (GSerialize a, GSerialize b) => GSerialize (a :*: b) 30 | > + instance GSerialize U1 31 | > + instance GSerialize a => GetSum (C1 c a) 32 | > + instance (GetSum a, GetSum b, GSerialize a, GSerialize b) => GetSum (a :+: b) 33 | > + instance GSerialize a => PutSum (C1 c a) 34 | > + instance (PutSum a, PutSum b, GSerialize a, GSerialize b) => PutSum (a :+: b) 35 | > + instance SumSize (C1 c a) 36 | > + instance (SumSize a, SumSize b) => SumSize (a :+: b) 37 | > + decodeLazy :: Serialize a => ByteString -> Either String a 38 | > + encodeLazy :: Serialize a => a -> ByteString 39 | > - data Get a 40 | > - type Put = PutM () 41 | > - type Putter a = a -> Put 42 | > - getWord8 :: Get Word8 43 | > - putWord8 :: Putter Word8 44 | > × Data.Serialize.Get 45 | > + Done :: r -> ByteString -> Result r 46 | > + instance Eq More 47 | > + Fail :: String -> Result r 48 | > + instance Functor Result 49 | > + Partial :: (ByteString -> Result r) -> Result r 50 | > + data Result r 51 | > + instance Show r => Show (Result r) 52 | > + ensure :: Int -> Get ByteString 53 | > + runGetLazy :: Get a -> ByteString -> Either String a 54 | > + runGetLazyState :: Get a -> ByteString -> Either String (a, ByteString) 55 | > + runGetPartial :: Get a -> ByteString -> Result a 56 | > × New: isolate :: Int -> Get a -> Get a 57 | > Old: isolate :: String -> Int -> Get a -> Get a 58 | > × Data.Serialize.Put 59 | > + runPutLazy :: Put -> ByteString 60 | > + runPutMLazy :: PutM a -> (a, ByteString) 61 | > · Data.Serialize.Builder 62 | > 63 | > [+ Added] [- Removed] [× Modified] [· Unmodified] 64 | 65 | license: MIT 66 | license-file: LICENSE 67 | author: Tim C. Schroeder 68 | maintainer: www.blitzcode.net 69 | homepage: https://github.com/blitzcode/hackage-diff 70 | bug-reports: https://github.com/blitzcode/hackage-diff/issues 71 | copyright: (C) 2016 Tim C. Schroeder 72 | category: Distribution 73 | build-type: Simple 74 | -- extra-source-files: 75 | cabal-version: >=1.18 76 | 77 | source-repository head 78 | type: git 79 | location: git@github.com:blitzcode/hackage-diff.git 80 | 81 | executable hackage-diff 82 | main-is: Main.hs 83 | -- other-modules: 84 | -- other-extensions: 85 | build-depends: base, 86 | Cabal, 87 | haskell-src-exts, 88 | ansi-terminal, 89 | directory, 90 | filepath, 91 | process, 92 | attoparsec, 93 | cpphs, 94 | mtl, 95 | text, 96 | HTTP, 97 | async 98 | -- hs-source-dirs: 99 | default-language: Haskell2010 100 | ghc-options: -Wall -O2 -rtsopts 101 | ghc-prof-options: -fprof-auto -caf-all 102 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # hackage-diff 2 | 3 | Compare the public API of different versions of a Hackage library. Detect breaking changes before you update or upload a new version. 4 | 5 | Also available on [Hackage](http://hackage.haskell.org/package/hackage-diff). 6 | 7 | Sample output for `hackage-diff cereal 0.2 0.3.5.2`: 8 | 9 | ``` 10 | Downloading Hoogle DBs... 11 | Parsing Hoogle DBs... 12 | Comparing Hoogle DBs... 13 | 14 | --- Diff for | 0.2 → 0.3.5.2 | --- 15 | 16 | + Data.Serialize.IEEE754 17 | + getFloat32be :: Get Float 18 | + getFloat32le :: Get Float 19 | + getFloat64be :: Get Double 20 | + getFloat64le :: Get Double 21 | + putFloat32be :: Float -> Put 22 | + putFloat32le :: Float -> Put 23 | + putFloat64be :: Double -> Put 24 | + putFloat64le :: Double -> Put 25 | × Data.Serialize 26 | + instance Serialize a => GSerialize (K1 i a) 27 | + instance GSerialize a => GSerialize (M1 i c a) 28 | + instance (GSerialize a, GSerialize b) => GSerialize (a :*: b) 29 | + instance GSerialize U1 30 | + instance GSerialize a => GetSum (C1 c a) 31 | + instance (GetSum a, GetSum b, GSerialize a, GSerialize b) => GetSum (a :+: b) 32 | + instance GSerialize a => PutSum (C1 c a) 33 | + instance (PutSum a, PutSum b, GSerialize a, GSerialize b) => PutSum (a :+: b) 34 | + instance SumSize (C1 c a) 35 | + instance (SumSize a, SumSize b) => SumSize (a :+: b) 36 | + decodeLazy :: Serialize a => ByteString -> Either String a 37 | + encodeLazy :: Serialize a => a -> ByteString 38 | - data Get a 39 | - type Put = PutM () 40 | - type Putter a = a -> Put 41 | - getWord8 :: Get Word8 42 | - putWord8 :: Putter Word8 43 | × Data.Serialize.Get 44 | + Done :: r -> ByteString -> Result r 45 | + instance Eq More 46 | + Fail :: String -> Result r 47 | + instance Functor Result 48 | + Partial :: (ByteString -> Result r) -> Result r 49 | + data Result r 50 | + instance Show r => Show (Result r) 51 | + ensure :: Int -> Get ByteString 52 | + runGetLazy :: Get a -> ByteString -> Either String a 53 | + runGetLazyState :: Get a -> ByteString -> Either String (a, ByteString) 54 | + runGetPartial :: Get a -> ByteString -> Result a 55 | × New: isolate :: Int -> Get a -> Get a 56 | Old: isolate :: String -> Int -> Get a -> Get a 57 | × Data.Serialize.Put 58 | + runPutLazy :: Put -> ByteString 59 | + runPutMLazy :: PutM a -> (a, ByteString) 60 | · Data.Serialize.Builder 61 | 62 | [+ Added] [- Removed] [× Modified] [· Unmodified] 63 | 64 | 6 potential breaking changes found 65 | ``` 66 | 67 | Terminal output can be colorized. 68 | 69 | # Usage 70 | 71 | ``` 72 | hackage-diff | Compare the public API of different versions of a Hackage library 73 | github.com/blitzcode/hackage-diff | www.blitzcode.net | (C) 2014 Tim C. Schroeder 74 | 75 | Usage: hackage-diff [options] 76 | --mode=[downloaddb|builddb|parsehs] what to download / read, how to compare 77 | downloaddb - download Hoogle DBs and diff (Default) 78 | builddb - download packages, build Hoogle DBs and diff 79 | parsehs - download packages, directly diff .hs exports 80 | -c --disable-color disable color output 81 | -s --silent disable progress output 82 | 83 | Examples: 84 | hackage-diff mtl 2.1 2.2.1 85 | hackage-diff --mode=builddb JuicyPixels 3.1.4.1 3.1.5.2 86 | hackage-diff conduit 1.1.5 ~/tmp/conduit-1.1.6/dist/doc/html/conduit/conduit.txt 87 | hackage-diff --mode=parsehs QuickCheck 2.6 2.7.6 88 | hackage-diff --mode=parsehs -s Cabal ~/tmp/Cabal-1.18.0/ 1.20.0.0 89 | ``` 90 | 91 | As the examples hopefully illustrate, you can choose to specify a local package / Hoogle database file instead of a version to be downloaded from Hackage. 92 | 93 | # Modes 94 | 95 | `hackage-diff` can operate in three different modes which determine how it obtains and parses the information about the packages to be compared. 96 | 97 | ### downloaddb 98 | 99 | Download the Hoogle databases for both packages from Hackage, then parse and diff them. This is the default and recommended mode of operation. Sometimes Hackage does not have a Hoogle database for a particular version available. In this case, running with `builddb` might be more successful. 100 | 101 | Alternatively, you can also specify a path to any local Hoogle database file for one or both versions. The default way to build one is `cabal haddock --hoogle`, outputting to `dist/doc/html/mypackage/mypackage.txt`. This can be used, among other things, to check a package you're working on for breaking API changes relative to what's on Hackage. 102 | 103 | ### builddb 104 | 105 | Download the package sources from Hackage, setup sandboxes, install dependencies, configure and use Haddock to build the Hoogle databases, parse and diff them. 106 | 107 | This is often very time consuming due to the dependency installation required for the Haddock build. Sometimes Haddock builds will fail, especially for older packages. As with `downloaddb`, a local Hoogle database file can be specified instead of a version. 108 | 109 | ### parsehs 110 | 111 | Download the package sources from Hackage, parse `.cabal` file for exported modules, pre-process them with `cpphs`, parse them with `haskel-src-exts` and diff their export lists. 112 | 113 | This mode has many downsides. Packages making heavy use of the CPP will often fail to be parsed as the pre-processing done here is not identical to what a Cabal build would do. `haskel-src-exts` sometimes fails to parse code that GHC would accept. We only look at the export lists, so modules without an explicit one will fail to be parsed correctly. There's no inspection of the types of exports, only names will be compared. 114 | 115 | Instead of a version to download, a path to a local package can be specified instead. 116 | 117 | # TODO 118 | 119 | This tools has various shortcomings and limitations and has only received a small amount of testing. Please let me know if you find an issue. Also see the various `TODO` comments scattered throughout the code. 120 | 121 | # Legal 122 | 123 | This program is published under the [MIT License](http://en.wikipedia.org/wiki/MIT_License). 124 | 125 | # Author 126 | 127 | Developed by Tim C. Schroeder, visit my [website](http://www.blitzcode.net) to learn more. 128 | -------------------------------------------------------------------------------- /Main.hs: -------------------------------------------------------------------------------- 1 | 2 | {-# LANGUAGE LambdaCase 3 | , ScopedTypeVariables 4 | , OverloadedStrings 5 | , RecordWildCards #-} 6 | 7 | module Main (main) where 8 | 9 | import System.Exit 10 | import System.Directory 11 | import System.Environment 12 | import System.FilePath 13 | import System.Process 14 | import System.Console.GetOpt 15 | import System.Console.ANSI 16 | import Control.Monad 17 | import Control.Monad.Except 18 | import Control.Monad.State 19 | import Control.Applicative 20 | import Control.Exception 21 | import Control.Concurrent.Async 22 | import Data.Function 23 | import Data.List 24 | import Data.Either 25 | import Data.Char 26 | import qualified Data.Text as T 27 | import qualified Data.Text.IO as TI 28 | import Data.Attoparsec.Text hiding (try) 29 | import Data.Attoparsec.Combinator (lookAhead) 30 | import Text.Printf 31 | import Distribution.PackageDescription 32 | import Distribution.PackageDescription.Parse 33 | import Distribution.Verbosity (normal) 34 | import Distribution.Simple.Utils (findPackageDesc) 35 | import Distribution.ModuleName (toFilePath, components) 36 | import Language.Haskell.Exts as E 37 | import Language.Preprocessor.Cpphs 38 | import Network.HTTP 39 | 40 | main :: IO () 41 | main = do 42 | -- Process command line arguments 43 | (pkgName, argVerA, argVerB, flags) <- 44 | runExcept <$> (getCmdOpt <$> getProgName <*> getArgs) >>= either die return 45 | when (argVerA == argVerB) $ 46 | die "Need to specify different versions / packages for comparison" 47 | mode <- case foldr (\f r -> case f of FlagMode m -> m; _ -> r) "downloaddb" flags of 48 | "downloaddb" -> return ModeDownloadDB 49 | "builddb" -> return ModeBuildDB 50 | "parsehs" -> return ModeParseHS 51 | m -> die $ printf "'%s' is not a valid mode" m 52 | let disableColor = FlagDisableColor `elem` flags 53 | silentFlag = FlagSilent `elem` flags 54 | -- Did we get a package version, DB path or package path? 55 | ([verA, verB] :: [EitherVerPath]) <- forM [argVerA, argVerB] $ \ver -> 56 | case parseOnly pkgVerParser (T.pack ver) of 57 | -- Not a version, check if we got a valid DB file or package path 58 | Left _ | mode == ModeParseHS -> do 59 | flip unless (die $ errHdr ++ " or package path" ) =<< doesDirectoryExist ver 60 | return $ Right ver 61 | | otherwise -> do 62 | flip unless (die $ errHdr ++ " or database path") =<< doesFileExist ver 63 | return $ Right ver 64 | where errHdr = "'" ++ ver ++ "' is not a valid version string (1.0[.0[.0]])" 65 | -- Looks like a valid version string 66 | Right _ -> return $ Left ver 67 | diff <- withTmpDirectory $ \tmpDir -> do 68 | -- Need to download packages? 69 | when (mode `elem` [ModeBuildDB, ModeParseHS]) $ 70 | forM_ (lefts [verA, verB]) $ \verString -> do 71 | let pkg = pkgName ++ "-" ++ verString 72 | unless silentFlag . putStrLn $ "Downloading " ++ pkg ++ "..." 73 | runExceptT (downloadPackage pkg tmpDir) >>= either die return 74 | -- Parse, compute difference 75 | either die return =<< 76 | ( runExceptT $ 77 | let cp = ComputeParams tmpDir pkgName verA verB silentFlag 78 | in case mode of 79 | ModeDownloadDB -> computeDiffDownloadHoogleDB cp 80 | ModeBuildDB -> computeDiffBuildHoogleDB cp 81 | ModeParseHS -> computeDiffParseHaskell cp 82 | ) 83 | -- Output results 84 | unless silentFlag $ printf "\n--- Diff for | %s → %s | ---\n\n" 85 | (either id id verA) 86 | (either id id verB) 87 | outputDiff diff disableColor silentFlag 88 | 89 | data FlagMode = ModeDownloadDB | ModeBuildDB | ModeParseHS 90 | deriving (Eq) 91 | 92 | data CmdFlag = FlagDisableColor | FlagSilent | FlagMode String 93 | deriving (Eq) 94 | 95 | getCmdOpt :: String -> [String] -> Except String (String, String, String, [CmdFlag]) 96 | getCmdOpt prgName args = 97 | case getOpt RequireOrder opt args of 98 | (flags, (pkgName:verA:verB:[]), []) -> return (pkgName, verA, verB, flags) 99 | (_, _, []) -> throwError usage 100 | (_, _, err) -> throwError (concat err ++ "\n" ++ usage) 101 | where 102 | header = 103 | "hackage-diff | Compare the public API of different versions of a Hackage library\n" ++ 104 | "github.com/blitzcode/hackage-diff | www.blitzcode.net | (C) 2016 Tim C. Schroeder\n\n" ++ 105 | "Usage: " ++ prgName ++ " [options] " 106 | footer = 107 | "\nExamples:\n" ++ 108 | " " ++ prgName ++ " mtl 2.1 2.2.1\n" ++ 109 | " " ++ prgName ++ " --mode=builddb JuicyPixels 3.1.4.1 3.1.5.2\n" ++ 110 | " " ++ prgName ++ " conduit 1.1.5 ~/tmp/conduit-1.1.6/dist/doc/html/conduit/conduit.txt\n" ++ 111 | " " ++ prgName ++ " --mode=parsehs QuickCheck 2.6 2.7.6\n" ++ 112 | " " ++ prgName ++ " --mode=parsehs -s Cabal ~/tmp/Cabal-1.18.0/ 1.20.0.0\n" 113 | usage = usageInfo header opt ++ footer 114 | opt = [ Option [] 115 | ["mode"] 116 | (ReqArg FlagMode "[downloaddb|builddb|parsehs]") 117 | ( "what to download / read, how to compare\n" ++ 118 | " downloaddb - download Hoogle DBs and diff (Default)\n" ++ 119 | " builddb - download packages, build Hoogle DBs and diff\n" ++ 120 | " parsehs - download packages, directly diff .hs exports" 121 | ) 122 | , Option ['c'] 123 | ["disable-color"] 124 | (NoArg FlagDisableColor) 125 | "disable color output" 126 | , Option ['s'] 127 | ["silent"] 128 | (NoArg FlagSilent) 129 | "disable progress output" 130 | ] 131 | 132 | -- Check a package version string (1.0[.0[.0]]) 133 | pkgVerParser :: Parser () 134 | pkgVerParser = (nDigits 4 <|> nDigits 3 <|> nDigits 2) *> endOfInput 135 | where digitInt = void (decimal :: Parser Int) 136 | nDigits n = count (n - 1) (digitInt *> char '.') *> digitInt 137 | 138 | -- Create and clean up temporary working directory 139 | withTmpDirectory :: (FilePath -> IO a) -> IO a 140 | withTmpDirectory = bracket 141 | ( do sysTmpDir <- getTemporaryDirectory 142 | let tmpDir = addTrailingPathSeparator $ sysTmpDir "hackage-diff" 143 | createDirectoryIfMissing True tmpDir 144 | return tmpDir 145 | ) 146 | ( removeDirectoryRecursive ) 147 | 148 | cabalInstall :: [String] -> ExceptT String IO () 149 | cabalInstall args = do 150 | (cabalExit, _, cabalStdErr) <- liftIO $ readProcessWithExitCode "cabal" args [] 151 | unless (cabalExit == ExitSuccess) . throwError $ cabalStdErr 152 | 153 | -- Use cabal-install to download a package from hackage 154 | downloadPackage :: String -> FilePath -> ExceptT String IO () 155 | downloadPackage pkg destination = cabalInstall [ "get", pkg, "--destdir=" ++ destination ] 156 | 157 | data ExportCmp = EAdded | ERemoved | EModified String {- Old signature -} | EUnmodified 158 | deriving (Show, Eq, Ord) 159 | 160 | data ModuleCmp = MAdded [String] -- Module was added 161 | | MAddedParseError -- Like above, but we couldn't parse the new one 162 | | MRemoved [String] -- Module was removed 163 | | MRemovedParseError -- Like above, but we couldn't parse the old one 164 | | MNotSureIfModifiedParseError -- New and/or old didn't parse, can't tell 165 | | MModified [(ExportCmp, String)] -- Modified 166 | | MUnmodifed -- Changed 167 | deriving (Show, Eq, Ord) 168 | 169 | type Diff = [(ModuleCmp, String)] 170 | 171 | -- Print out the computed difference, optionally with ANSI colors 172 | outputDiff :: Diff -> Bool -> Bool -> IO () 173 | outputDiff diff disableColor disableLengend = do 174 | let putStrCol color str 175 | | disableColor = liftIO $ putStr str 176 | | otherwise = liftIO . putStr $ setSGRCode [SetColor Foreground Vivid color] ++ 177 | str ++ setSGRCode [Reset] 178 | putStrLnCol color str = liftIO $ putStrCol color str >> putStrLn "" 179 | breakingChanges <- flip execStateT (0 :: Int) . forM_ diff $ \case 180 | (MAdded exps , mname) -> do 181 | putStrLnCol Green $ "+ " ++ mname 182 | mapM_ (putStrLnCol Green . printf " + %s") exps 183 | (MAddedParseError , mname) -> 184 | putStrLnCol Green $ 185 | printf " + %s (ERROR: failed to parse new version, exports not available)" mname 186 | (MRemoved exps , mname) -> do 187 | putStrLnCol Red $ "- " ++ mname 188 | mapM_ (\e -> modify' (+ 1) >> putStrLnCol Red (printf " - %s" e)) exps 189 | (MRemovedParseError , mname) -> do 190 | modify' (+ 1) 191 | putStrLnCol Red $ 192 | " - " ++ mname ++ " (ERROR: failed to parse old version, exports not available)" 193 | (MNotSureIfModifiedParseError, mname) -> do 194 | putStrLnCol Yellow $ "× " ++ mname ++ 195 | " (Potentially modified, ERROR: failed to parse new and/or old version)" 196 | (MModified exps , mname) -> do 197 | putStrLnCol Yellow $ "× " ++ mname 198 | forM_ exps $ \(cmp, expname) -> case cmp of 199 | EAdded -> putStrLnCol Green $ " + " ++ expname 200 | ERemoved -> do modify' (+ 1) 201 | putStrLnCol Red $ " - " ++ expname 202 | EModified old -> do modify' (+ 1) 203 | putStrLnCol Yellow $ " × New: " ++ expname ++ "\n" ++ 204 | " Old: " ++ old 205 | EUnmodified -> return () 206 | (MUnmodifed , mname) -> putStrLnCol White $ "· " ++ mname 207 | unless disableLengend $ do 208 | putStrLn "" 209 | putStrCol Green "[+ Added] " 210 | putStrCol Red "[- Removed] " 211 | putStrCol Yellow "[× Modified] " 212 | putStrCol White "[· Unmodified]\n" 213 | unless (breakingChanges == 0) $ 214 | putStrLnCol Red $ printf "\n%i potential breaking changes found" breakingChanges 215 | 216 | -- All the parameters required by the various compute* functions that actually prepare the 217 | -- data and compute the difference 218 | data ComputeParams = ComputeParams { cpTmpDir :: FilePath 219 | , cpPackage :: String 220 | , cpVerA :: EitherVerPath 221 | , cpVerB :: EitherVerPath 222 | , cpSilentFlag :: Bool 223 | } deriving (Eq, Show) 224 | 225 | -- A package can be specified by a version string, a Hoogle DB file path or a package path 226 | type VersionString = String 227 | type EitherVerPath = Either VersionString FilePath 228 | 229 | -- Compute a Diff by comparing the package's Hoogle DB read from disk or downloaded from Hackage 230 | computeDiffDownloadHoogleDB :: ComputeParams -> ExceptT String IO Diff 231 | computeDiffDownloadHoogleDB ComputeParams { .. } = do 232 | -- Get Hoogle databases 233 | putS "Downloading / Reading Hoogle DBs..." 234 | (dbA, dbB) <- 235 | either (\(e :: IOException) -> throwError $ "DB Error: " ++ show e ++ tip) return =<< 236 | (liftIO . try $ concurrently (downloadOrRead cpVerA) (downloadOrRead cpVerB)) 237 | -- Parse 238 | putS "Parsing Hoogle DBs..." 239 | [parsedDBA, parsedDBB] <- forM [dbA, dbB] $ \db -> 240 | either throwError return $ parseOnly (hoogleDBParser <* endOfInput) db 241 | -- Debug parser in GHCi: parseOnly hoogleDBParser <$> TI.readFile "base.txt" >>= 242 | -- \(Right db) -> mapM_ (putStrLn . show) db 243 | -- Compare 244 | putS "Comparing Hoogle DBs..." 245 | return $ diffHoogleDB parsedDBA parsedDBB 246 | where getHoogleDBURL ver = "http://hackage.haskell.org/package" cpPackage ++ "-" ++ ver 247 | "docs" cpPackage <.> "txt" 248 | -- Network.HTTP is kinda crummy, but pulling in http-client/conduit 249 | -- just for downloading two small text files is probably not worth it 250 | downloadURL url = T.pack <$> do 251 | req <- simpleHTTP (getRequest url) 252 | -- HTTP will throw an IOException for any connection error, 253 | -- also examine the response code and throw one for every 254 | -- non-200 one we get 255 | code <- getResponseCode req 256 | unless (code == (2, 0, 0)) . throwIO . userError $ 257 | "Status code " ++ show code ++ " for request " ++ url 258 | getResponseBody req 259 | tip = "\nYou can try building missing Hoogle DBs yourself by running with --mode=builddb" 260 | putS = unless cpSilentFlag . liftIO . putStrLn 261 | downloadOrRead = either (downloadURL . getHoogleDBURL) (TI.readFile) 262 | 263 | -- Compute a Diff by comparing the package's Hoogle DB build through Haddock. Unfortunately, 264 | -- running Haddock requires to have the package configured with all dependencies 265 | -- installed. This can often be very slow and frequently fails for older packages, on top 266 | -- of any Haddock failures that might happen 267 | computeDiffBuildHoogleDB :: ComputeParams -> ExceptT String IO Diff 268 | computeDiffBuildHoogleDB ComputeParams { .. } = 269 | flip catchError (\e -> throwError $ e ++ tip) $ do 270 | forM_ (lefts [cpVerA, cpVerB]) $ \ver -> do -- Only build if we don't have a DB file path 271 | let pkg = cpPackage ++ "-" ++ ver 272 | putS $ "Processing " ++ pkg ++ "..." 273 | -- TODO: This is rather ugly. Cabal does not allow us to specify the target 274 | -- directory, and the current directory is not a per-thread property. 275 | -- While createProcess allows the specification of a working directory, our 276 | -- preferred wrapper readProcessWithExitCode does not expose that. 277 | -- Duplicating that function and its web of private helpers here would be 278 | -- quite some overhead. For now we simply change the working directory of 279 | -- the process 280 | -- 281 | -- https://ghc.haskell.org/trac/ghc/ticket/9322#ticket 282 | -- 283 | liftIO . setCurrentDirectory $ cpTmpDir pkg 284 | -- All the steps required to get the Hoogle DB 285 | putS " Creating Sandbox" >> cabalInstall [ "sandbox", "init" ] 286 | putS " Installing Dependencies" >> cabalInstall [ "install" 287 | , "--dependencies-only" 288 | -- Try building as fast as 289 | -- possible 290 | , "-j" 291 | , "--disable-optimization" 292 | , "--ghc-option=-O0" 293 | , "--disable-library-for-ghci" 294 | ] 295 | putS " Configuring" >> cabalInstall [ "configure" ] 296 | putS " Building Haddock" >> cabalInstall [ "haddock", "--hoogle" ] 297 | -- Read DBs from disk 298 | [dbA, dbB] <- 299 | forM [cpVerA, cpVerB] $ \ver -> 300 | (liftIO . try . TI.readFile $ either getHoogleDBPath id ver) 301 | >>= either (\(e :: IOException) -> throwError $ show e) return 302 | -- Parse 303 | [parsedDBA, parsedDBB] <- forM [dbA, dbB] $ \db -> 304 | either throwError return $ parseOnly hoogleDBParser db 305 | -- Compare 306 | return $ diffHoogleDB parsedDBA parsedDBB 307 | where 308 | putS = unless cpSilentFlag . liftIO . putStrLn 309 | getHoogleDBPath ver = cpTmpDir cpPackage ++ "-" ++ ver "dist/doc/html" 310 | cpPackage cpPackage <.> "txt" 311 | tip = "\nIf downloading / building Hoogle DBs fails, you can try directly parsing " ++ 312 | "the source files by running with --mode=parsehs" 313 | 314 | -- Compare two packages made up of readily parsed Hoogle DBs 315 | diffHoogleDB :: [DBEntry] -> [DBEntry] -> Diff 316 | diffHoogleDB dbA dbB = do 317 | let [verA, verB] = flip map [dbA, dbB] 318 | ( -- Sort exports by name 319 | map (\(nm, exps) -> (nm, sortBy (compare `on` dbeName) exps)) 320 | -- Sort modules by name 321 | . sortBy (compare `on` fst) 322 | -- Extract module name, put into (name, exports) pair 323 | . map (\case ((DBModule nm):exps) -> (nm , exps) 324 | exps -> ("(Unknown)", exps) 325 | ) 326 | -- Group by module 327 | . groupBy (\a b -> or $ (\case DBModule _ -> False 328 | _ -> True 329 | ) <$> [a, b] 330 | ) 331 | -- Filter out comments and package information 332 | . filter (\case (DBPkgInfo _ _) -> False 333 | (DBComment _ ) -> False 334 | _ -> True 335 | ) 336 | ) 337 | modulesAdded = allANotInBBy ((==) `on` fst) verB verA 338 | modulesRemoved = allANotInBBy ((==) `on` fst) verA verB 339 | modulesKept = intersectBy ((==) `on` fst) verA verB 340 | resAdded = flip map modulesAdded $ \(nm, exps) -> 341 | (MAdded . map (show) $ exps, T.unpack nm) 342 | resRemoved = flip map modulesRemoved $ \(nm, exps) -> 343 | (MRemoved . map (show) $ exps, T.unpack nm) 344 | resKept = 345 | sortBy compareKept . flip map modulesKept $ \(mname, modA') -> 346 | -- Did the exports change? 347 | case (modA', snd <$> find ((== mname) . fst) verB) of 348 | (_ , Nothing ) -> -- This really should not ever happen here 349 | (MNotSureIfModifiedParseError, T.unpack mname) 350 | (modA, Just modB) 351 | | didExpChange -> (MModified expCmp , T.unpack mname) 352 | | otherwise -> (MUnmodifed , T.unpack mname) 353 | where -- Which exports were added / removed / modified? 354 | didExpChange = or $ map (\case (EUnmodified, _) -> False; _ -> True) expCmp 355 | expCmp = expAdded ++ expRemoved ++ expKept 356 | expAdded = 357 | [(EAdded , show x) | x <- allANotInBBy compareDBEName modB modA] 358 | expRemoved = 359 | [(ERemoved, show x) | x <- allANotInBBy compareDBEName modA modB] 360 | expKept = 361 | -- We don't sort by modified / unmodified here as we currently 362 | -- don't list the unmodified ones 363 | flip map (intersectBy compareDBEName modA modB) $ \eOld -> 364 | case find (compareDBEName eOld) modB of 365 | Nothing -> error "intersectBy / find is broken..." 366 | Just eNew | compareDBEType eOld eNew -> 367 | (EUnmodified, show eOld) 368 | | otherwise -> 369 | (EModified $ show eOld, show eNew) 370 | -- Sort everything by modification type, but make sure we sort 371 | -- modified modules by their name, not their export list 372 | compareKept a b = case (a, b) of 373 | ((MModified _, nameA), (MModified _, nameB)) -> compare nameA nameB 374 | _ -> compare a b 375 | in resAdded ++ resRemoved ++ resKept 376 | 377 | -- Stupid helper to build module / export lists. Should probably switch to using 378 | -- Data.Set for all of these operations to stop having O(n*m) everywhere 379 | allANotInBBy :: (a -> a -> Bool) -> [a] -> [a] -> [a] 380 | allANotInBBy f a b = filter (\m -> not $ any (f m) b) a 381 | 382 | data DBEntry = DBModule !T.Text 383 | | DBPkgInfo !T.Text !T.Text 384 | | DBComment !T.Text 385 | | DBType !T.Text !T.Text 386 | | DBNewtype !T.Text !T.Text 387 | | DBData !T.Text !T.Text 388 | | DBCtor !T.Text !T.Text 389 | | DBClass !T.Text !T.Text 390 | | DBInstance !T.Text !T.Text 391 | | DBFunction !T.Text !T.Text 392 | deriving (Eq) 393 | 394 | -- When comparing names we have to take the kind of the export into account, i.e. 395 | -- type and value constructors may have the same name without being identical 396 | compareDBEName :: DBEntry -> DBEntry -> Bool 397 | compareDBEName a b = case (a, b) of 398 | (DBModule _ , DBModule _ ) -> cmp; (DBPkgInfo _ _ , DBPkgInfo _ _ ) -> cmp; 399 | (DBComment _ , DBComment _ ) -> cmp; (DBType _ _ , DBType _ _ ) -> cmp; 400 | (DBNewtype _ _ , DBNewtype _ _ ) -> cmp; (DBData _ _ , DBData _ _ ) -> cmp; 401 | (DBCtor _ _ , DBCtor _ _ ) -> cmp; (DBClass _ _ , DBClass _ _ ) -> cmp; 402 | (DBInstance _ _, DBInstance _ _) -> cmp; (DBFunction _ _, DBFunction _ _) -> cmp; 403 | _ -> False 404 | where cmp = ((==) `on` dbeName) a b 405 | 406 | -- Compare the type of two entries. If we simply compare the type string, we will 407 | -- have mistakes like classifying those two functions as having a change in type: 408 | -- 409 | -- func :: Num a => a -> a 410 | -- func :: (Num a) => a -> a 411 | -- 412 | -- So we try to parse the type with haskell-src-exts and then fall back on a string 413 | -- compare if that fails. Parsing again every time the comparison function is called is 414 | -- obviously rather slow, but it hasn't been an issue so far 415 | -- 416 | -- TODO: We should do a name normalization pass on the parsed type, otherwise 417 | -- 'id :: a -> a' and 'id :: b -> b' will be reported as different 418 | -- 419 | compareDBEType :: DBEntry -> DBEntry -> Bool 420 | compareDBEType a b = 421 | -- We assume that a and b are the same kind of export (i.e. they have already been 422 | -- matched with dbeName, which only compares exports of the same kind), and now we 423 | -- want to know if the type differs between them 424 | case a of 425 | -- The syntax we use to list exported Ctors and their types can't be parsed as a 426 | -- declaration, just compare the type part 427 | DBCtor _ _ -> case ( parseTypeWithMode mode . T.unpack $ dbeType a 428 | , parseTypeWithMode mode . T.unpack $ dbeType b 429 | ) of 430 | (E.ParseOk resA, E.ParseOk resB) -> resA == resB 431 | _ -> stringTypeCmp 432 | -- Also can't parse our type / newtype syntax, fall back to string compare 433 | DBType _ _ -> stringTypeCmp 434 | DBNewtype _ _ -> stringTypeCmp 435 | -- Parse everything else in its entirety as a top-level declaration 436 | _ -> case ( parseDeclWithMode mode $ show a 437 | , parseDeclWithMode mode $ show b 438 | ) of 439 | (E.ParseOk resA, E.ParseOk resB) -> resA == resB 440 | _ -> stringTypeCmp 441 | where mode = -- Enable some common extension to make parsing more likely to succeed 442 | defaultParseMode 443 | { 444 | extensions = [ EnableExtension FunctionalDependencies 445 | , EnableExtension MultiParamTypeClasses 446 | , EnableExtension TypeOperators 447 | , EnableExtension KindSignatures 448 | , EnableExtension MagicHash 449 | , EnableExtension FlexibleContexts 450 | ] 451 | } 452 | stringTypeCmp = ((==) `on` dbeType) a b 453 | 454 | -- Extract a database entry's "name" (i.e. a function name vs its type) 455 | dbeName :: DBEntry -> T.Text 456 | dbeName = \case 457 | DBModule nm -> nm; DBPkgInfo k _ -> k ; DBComment _ -> ""; 458 | DBType nm _ -> nm; DBNewtype nm _ -> nm; DBData nm _ -> nm; 459 | DBCtor nm _ -> nm; DBClass nm _ -> nm; DBInstance nm _ -> nm; 460 | DBFunction nm _ -> nm 461 | 462 | -- Extract a database entry's "type" (i.e. a function type vs its name) 463 | dbeType :: DBEntry -> T.Text 464 | dbeType = \case 465 | DBModule _ -> ""; DBPkgInfo _ v -> v ; DBComment _ -> ""; 466 | DBType _ ty -> ty; DBNewtype _ ty -> ty; DBData _ ty -> ty; 467 | DBCtor _ ty -> ty; DBClass _ ty -> ty; DBInstance _ ty -> ty; 468 | DBFunction _ ty -> ty 469 | 470 | instance Show DBEntry where 471 | show = \case 472 | DBModule nm -> "module " ++ T.unpack nm 473 | DBPkgInfo k v -> "@" ++ T.unpack k ++ T.unpack v 474 | DBComment txt -> "-- " ++ T.unpack txt 475 | DBType nm ty -> "type " ++ T.unpack nm ++ " " ++ T.unpack ty 476 | DBNewtype nm ty -> "newtype " ++ T.unpack nm ++ " " ++ T.unpack ty 477 | DBData nm ty -> "data " ++ T.unpack nm ++ (if T.null ty then "" else " " ++ T.unpack ty) 478 | DBCtor nm ty -> T.unpack nm ++ " :: " ++ T.unpack ty 479 | DBClass _ ty -> "class " ++ T.unpack ty 480 | DBInstance _ ty -> "instance " ++ T.unpack ty 481 | DBFunction nm ty -> T.unpack nm ++ " :: " ++ T.unpack ty 482 | 483 | -- Parse a Hoogle text database 484 | hoogleDBParser :: Parser [DBEntry] 485 | hoogleDBParser = many parseLine 486 | where 487 | parseLine = (*>) skipEmpty $ parseComment <|> parseData <|> parsePkgInfo <|> 488 | parseDBModule <|> parseCtor <|> parseNewtype <|> 489 | parseDBType <|> parseClass <|> parseInstance <|> 490 | parseFunction 491 | parseComment = string "-- " *> (DBComment <$> tillEoL) 492 | parsePkgInfo = char '@' *> (DBPkgInfo <$> takeTill (== ' ') <*> tillEoL) 493 | parseData = string "data " *> 494 | ( (DBData <$> takeTill (`elem` [ ' ', '\n' ]) <* endOfLine <*> "") <|> 495 | (DBData <$> takeTill (== ' ') <* skipSpace <*> tillEoL) 496 | ) 497 | parseNewtype = string "newtype " *> 498 | ( (DBNewtype <$> takeTill (`elem` [ ' ', '\n' ]) <* endOfLine <*> "") <|> 499 | (DBNewtype <$> takeTill (== ' ') <* skipSpace <*> tillEoL) 500 | ) 501 | -- TODO: At some point Hoogle DBs started to have Ctors and functions 502 | -- names wrapped in brackets. Not sure what's up with that, just 503 | -- parse them as part of the name so the parser doesn't stop 504 | parseCtor = do void . lookAhead $ satisfy isAsciiUpper <|> 505 | (char '[' *> satisfy isAsciiUpper) 506 | DBCtor <$> takeTill (== ' ') <* string " :: " <*> tillEoL 507 | -- TODO: This doesn't parse function lists correctly 508 | parseFunction = do void . lookAhead $ satisfy isAsciiLower <|> char '[' <|> char '(' 509 | DBFunction <$> takeTill (== ' ') <* string " :: " <*> tillEoL 510 | parseInstance = do void $ string "instance " 511 | line <- T.words <$> tillEoL 512 | -- The name of an instance is basically everything 513 | -- after the typeclass requirements 514 | let nm = case break (== "=>") line of 515 | (xs, []) -> T.unwords xs 516 | (_, (_:xs)) -> T.unwords xs 517 | return . DBInstance nm $ T.unwords line 518 | parseClass = do void $ string "class " 519 | line <- T.words <$> tillEoL 520 | let nm = case break (== "=>") line of 521 | ((n:_), []) -> n 522 | (_, (_:n:_)) -> n 523 | _ -> "" 524 | -- TODO: Sometimes typeclasses have all their default method 525 | -- implementations listed right after the 'where' part, 526 | -- just cut all of this off for now 527 | trunc = fst . break (== "where") $ line 528 | in return . DBClass nm $ T.unwords trunc 529 | parseDBType = string "type " *> (DBType <$> takeTill (== ' ') <* skipSpace <*> tillEoL) 530 | parseDBModule = string "module " *> (DBModule <$> takeTill (== '\n')) <* endOfLine 531 | skipEmpty = many endOfLine 532 | tillEoL = takeTill (== '\n') <* endOfLine 533 | 534 | -- Compute a Diff by processing Haskell files directly. We use the Cabal API to locate and 535 | -- parse the package .cabal file, extract a list of modules from it, and then pre-process 536 | -- each module with cpphs and finally parse it with haskell-src-exts. The principal issue 537 | -- with this approach is the often complex use of the CPP inside Haskell packages, making 538 | -- this fail fairly often. This method also currently does not look at type signatures and 539 | -- has various other limitations, like not working with modules that do not have an 540 | -- export list 541 | computeDiffParseHaskell :: ComputeParams -> ExceptT String IO Diff 542 | computeDiffParseHaskell ComputeParams { .. } = do 543 | [mListA, mListB] <- forM [cpVerA, cpVerB] $ \ver -> do 544 | let pkgPath = either (\v -> cpTmpDir cpPackage ++ "-" ++ v) id ver 545 | unless cpSilentFlag . liftIO . putStrLn $ "Processing " ++ pkgPath ++ "..." 546 | -- Find .cabal file 547 | dotCabal <- (liftIO . findPackageDesc $ pkgPath) >>= either throwError return 548 | -- Parse .cabal file, extract exported modules 549 | exports <- condLibrary <$> (liftIO $ readGenericPackageDescription normal dotCabal) >>= \case 550 | Nothing -> throwError $ pkgPath ++ " is not a library" 551 | Just node -> return $ exposedModules . condTreeData $ node 552 | -- Build module name / module source file list 553 | -- 554 | -- TODO: Some packages have a more complex source structure, need to look at the 555 | -- cabal file some more to locate the files 556 | let modules = flip map exports $ 557 | \m -> ( concat . intersperse "." . components $ m 558 | , pkgPath toFilePath m <.> "hs" -- TODO: Also .lhs? 559 | ) 560 | -- Parse modules 561 | liftIO . forM modules $ \(modName, modPath) -> do 562 | unless cpSilentFlag . putStrLn $ " Parsing " ++ modName 563 | Main.parseModule modPath >>= either 564 | -- Errors only affecting single modules are recoverable, just 565 | -- print them instead of throwing 566 | (\e -> putStrLn (" " ++ e) >> return (modName, Nothing)) 567 | (\r -> return (modName, Just r )) 568 | -- Compute difference 569 | return $ comparePackageModules mListA mListB 570 | 571 | -- Parse a Haskell module interface using haskell-src-exts and cpphs 572 | parseModule :: FilePath -> IO (Either String (Module SrcSpanInfo)) 573 | parseModule modPath = runExceptT $ do 574 | (liftIO $ doesFileExist modPath) >>= flip unless 575 | (throwError $ "Can't open source file '" ++ modPath ++ "'") 576 | -- Run cpphs as pre-processor over our module 577 | -- 578 | -- TODO: This obviously doesn't have the same defines and include paths set like 579 | -- when compiling with GHC, major source of failures right now 580 | modSrcCPP <- liftIO $ readFile modPath >>= runCpphs defaultCpphsOptions modPath 581 | -- Parse pre-processed Haskell source. This pure parsing function unfortunately throws 582 | -- exceptions for things like encountering an '#error' directive in the code, so we 583 | -- also have to handle those as well 584 | (liftIO . try . evaluate $ 585 | parseFileContentsWithMode defaultParseMode { parseFilename = modPath } modSrcCPP) 586 | >>= \case Left (e :: ErrorCall) -> 587 | throwError $ "Haskell Parse Exception - " ++ show e 588 | Right (E.ParseFailed (SrcLoc fn ln cl) err) -> 589 | throwError $ printf "Haskell Parse Error - %s:%i:%i: %s" fn ln cl err 590 | Right (E.ParseOk parsedModule) -> 591 | return parsedModule 592 | 593 | type PackageModuleList = [(String, Maybe (Module SrcSpanInfo))] 594 | 595 | -- Compare two packages made up of readily parsed Haskell modules 596 | comparePackageModules :: PackageModuleList -> PackageModuleList -> Diff 597 | comparePackageModules verA verB = do 598 | let -- Compare lists of modules 599 | modulesAdded = allANotInBBy ((==) `on` fst) verB verA 600 | modulesRemoved = allANotInBBy ((==) `on` fst) verA verB 601 | modulesKept = intersectBy ((==) `on` fst) verA verB 602 | -- Build result Diff of modules 603 | resAdded = flip map modulesAdded $ \case 604 | (mname, Just m ) -> 605 | (MAdded . map (prettyPrint) $ moduleExports m, mname) 606 | (mname, Nothing) -> 607 | (MAddedParseError, mname) 608 | resRemoved = flip map modulesRemoved $ \case 609 | (mname, Just m ) -> 610 | (MRemoved . map (prettyPrint) $ moduleExports m, mname) 611 | (mname, Nothing) -> 612 | (MRemovedParseError, mname) 613 | -- TODO: This doesn't sort correctly by type of change + name 614 | resKept = sortBy (compare `on` fst) . flip map modulesKept $ \(mname, modA') -> 615 | -- Did the exports change? 616 | case (modA', findModule verB mname) of 617 | (_, Nothing) -> (MNotSureIfModifiedParseError, mname) 618 | (Nothing, _) -> (MNotSureIfModifiedParseError, mname) 619 | (Just modA, Just modB) 620 | | moduleExports modA == moduleExports modB 621 | -> (MUnmodifed , mname) 622 | | otherwise -> (MModified expCmp, mname) 623 | where -- Which exports were added / removed? 624 | expCmp = 625 | [(EAdded , prettyPrint x) | x <- expAdded ] ++ 626 | [(ERemoved , prettyPrint x) | x <- expRemoved ] ++ 627 | [(EUnmodified, prettyPrint x) | x <- expUnmodified] 628 | -- TODO: We do not look for type changes, no EModified 629 | expAdded = allANotInBBy (==) (moduleExports modB) 630 | (moduleExports modA) 631 | expRemoved = allANotInBBy (==) (moduleExports modA) 632 | (moduleExports modB) 633 | expUnmodified = intersectBy (==) (moduleExports modA) 634 | (moduleExports modB) 635 | -- TODO: If the module does not have an export spec, we assume it exports nothing 636 | moduleExports (Module _ (Just (ModuleHead _ _ _ (Just (ExportSpecList _ exportSpec)))) _ _ _ ) = exportSpec 637 | moduleExports _ = [] 638 | findModule mlist mname = maybe Nothing snd $ find ((== mname) . fst) mlist 639 | in resAdded ++ resRemoved ++ resKept 640 | 641 | --------------------------------------------------------------------------------