nix-diff-1.0.14: src/Main.hs
{-# LANGUAGE ApplicativeDo #-}
{-# LANGUAGE BlockArguments #-}
{-# LANGUAGE CPP #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
module Main where
import Control.Applicative ((<|>))
import Control.Monad (forM, forM_)
import Control.Monad.IO.Class (MonadIO, liftIO)
import Control.Monad.Reader (MonadReader, ReaderT, ask, local)
import Control.Monad.State (MonadState, StateT, get, put)
import Data.Attoparsec.Text (IResult(..))
import Data.Map (Map)
import Data.Monoid ((<>))
import Data.Set (Set)
import Data.Text (Text)
import Data.Vector (Vector)
import Nix.Derivation (Derivation, DerivationOutput)
import Numeric.Natural (Natural)
import Options.Applicative (Parser, ParserInfo)
import qualified Control.Monad as Monad
import qualified Control.Monad.Reader
import qualified Control.Monad.State
import qualified Data.Attoparsec.Text
import qualified Data.Char as Char
import qualified Data.Map
import qualified Data.List as List
import qualified Data.Set
import qualified Data.Text
import qualified Data.Text.IO
import qualified Data.Vector
import qualified GHC.IO.Encoding
import qualified Nix.Derivation
import qualified Options.Applicative
import qualified Patience
import qualified System.Directory as Directory
import qualified System.Posix.IO
import qualified System.Posix.Terminal
#if MIN_VERSION_base(4,9,0)
import Control.Monad.Fail (MonadFail)
#endif
data Color = Always | Auto | Never
parseColor :: Parser Color
parseColor =
Options.Applicative.option
reader
( Options.Applicative.long "color"
<> Options.Applicative.value Auto
<> Options.Applicative.metavar "(always|auto|never)"
)
where
reader = do
string <- Options.Applicative.str
case string of
"always" -> return Always
"auto" -> return Auto
"never" -> return Never
_ -> fail "Invalid color"
parseLineOriented :: Parser Orientation
parseLineOriented =
per "line" Line
<|> per "character" Character
<|> per "word" Word
<|> pure Word
where
per x orientation =
Options.Applicative.flag' orientation
( Options.Applicative.long (x <> "-oriented")
<> Options.Applicative.help ("Display textual differences on a per-" <> x <> " basis")
)
parseEnvironment :: Parser Bool
parseEnvironment =
Options.Applicative.switch
( Options.Applicative.long "environment"
<> Options.Applicative.help "Force display of environment differences"
)
data Options = Options
{ left :: FilePath
, right :: FilePath
, color :: Color
, orientation :: Orientation
, environment :: Bool
}
data Orientation = Character | Word | Line
parseOptions :: Parser Options
parseOptions = do
left <- parseLeft
right <- parseRight
color <- parseColor
orientation <- parseLineOriented
environment <- parseEnvironment
return (Options { left, right, color, orientation, environment })
where
parseFilePath metavar = do
Options.Applicative.strArgument
(Options.Applicative.metavar metavar)
parseLeft = parseFilePath "LEFT"
parseRight = parseFilePath "RIGHT"
parserInfo :: ParserInfo Options
parserInfo =
Options.Applicative.info
(Options.Applicative.helper <*> parseOptions)
( Options.Applicative.fullDesc
<> Options.Applicative.header "Explain why two derivations differ"
)
data Context = Context
{ tty :: TTY
, indent :: Natural
, orientation :: Orientation
, environment :: Bool
}
newtype Status = Status { visited :: Set Diffed }
data Diffed = Diffed
{ leftDerivation :: FilePath
, leftOutput :: Set Text
, rightDerivation :: FilePath
, rightOutput :: Set Text
} deriving (Eq, Ord)
newtype Diff a = Diff { unDiff :: ReaderT Context (StateT Status IO) a }
deriving
( Functor
, Applicative
, Monad
, MonadReader Context
, MonadState Status
, MonadIO
#if MIN_VERSION_base(4,9,0)
, MonadFail
#endif
)
echo :: Text -> Diff ()
echo text = do
Context { indent } <- ask
let n = fromIntegral indent
liftIO (Data.Text.IO.putStrLn (Data.Text.replicate n " " <> text))
indented :: Natural -> Diff a -> Diff a
indented n = local adapt
where
adapt context = context { indent = indent context + n }
pathToText :: FilePath -> Text
pathToText = Data.Text.pack
{-| Extract the name of a derivation (i.e. the part after the hash)
This is used to guess which derivations are related to one another, even
though their hash might differ
Note that this assumes that the path name is:
> /nix/store/${32_CHARACTER_HASH}-${NAME}.drv
Nix technically does not require that the Nix store is actually stored
underneath `/nix/store`, but this is the overwhelmingly common use case
-}
derivationName :: FilePath -> Text
derivationName = Data.Text.dropEnd 4 . Data.Text.drop 44 . pathToText
-- | Group paths by their name
groupByName :: Map FilePath a -> Map Text (Map FilePath a)
groupByName m = Data.Map.fromList assocs
where
toAssoc key = (derivationName key, Data.Map.filterWithKey predicate m)
where
predicate key' _ = derivationName key == derivationName key'
assocs = fmap toAssoc (Data.Map.keys m)
{-| Extract the name of a build product
Similar to `derivationName`, this assumes that the path name is:
> /nix/store/${32_CHARACTER_HASH}-${NAME}.drv
-}
buildProductName :: FilePath -> Text
buildProductName = Data.Text.drop 44 . pathToText
-- | Like `groupByName`, but for `Set`s
groupSetsByName :: Set FilePath -> Map Text (Set FilePath)
groupSetsByName s = Data.Map.fromList (fmap toAssoc (Data.Set.toList s))
where
toAssoc key = (buildProductName key, Data.Set.filter predicate s)
where
predicate key' = buildProductName key == buildProductName key'
-- | Read and parse a derivation from a file
readDerivation :: FilePath -> Diff (Derivation FilePath Text)
readDerivation path = do
let string = path
text <- liftIO (Data.Text.IO.readFile string)
case Data.Attoparsec.Text.parse Nix.Derivation.parseDerivation text of
Done _ derivation -> do
return derivation
_ -> do
fail ("Could not parse a derivation from this file: " ++ string)
{-| Join two `Map`s on shared keys, discarding keys which are not present in
both `Map`s
-}
innerJoin :: Ord k => Map k a -> Map k b -> Map k (a, b)
innerJoin = Data.Map.mergeWithKey both left right
where
both _ a b = Just (a, b)
left _ = Data.Map.empty
right _ = Data.Map.empty
data TTY = IsTTY | NotTTY
-- | Color text red
red :: TTY -> Text -> Text
red IsTTY text = "\ESC[1;31m" <> text <> "\ESC[0m"
red NotTTY text = text
-- | Color text background red
redBackground :: Orientation -> TTY -> Text -> Text
redBackground Line IsTTY text = "\ESC[41m" <> prefix <> "\ESC[0m" <> suffix
where
(prefix, suffix) = Data.Text.break lineBoundary text
redBackground Word IsTTY text = "\ESC[41m" <> prefix <> "\ESC[0m" <> suffix
where
(prefix, suffix) = Data.Text.break wordBoundary text
redBackground Character IsTTY text = "\ESC[41m" <> text <> "\ESC[0m"
redBackground Line NotTTY text = "- " <> text
redBackground _ NotTTY text = "←" <> text <> "←"
-- | Color text green
green :: TTY -> Text -> Text
green IsTTY text = "\ESC[1;32m" <> text <> "\ESC[0m"
green NotTTY text = text
-- | Color text background green
greenBackground :: Orientation -> TTY -> Text -> Text
greenBackground Line IsTTY text = "\ESC[42m" <> prefix <> "\ESC[0m" <> suffix
where
(prefix, suffix) = Data.Text.break lineBoundary text
greenBackground Word IsTTY text = "\ESC[42m" <> prefix <> "\ESC[0m" <> suffix
where
(prefix, suffix) = Data.Text.break wordBoundary text
greenBackground Character IsTTY text = "\ESC[42m" <> text <> "\ESC[0m"
greenBackground Line NotTTY text = "+ " <> text
greenBackground _ NotTTY text = "→" <> text <> "→"
-- | Color text grey
grey :: Orientation -> TTY -> Text -> Text
grey _ IsTTY text = "\ESC[1;2m" <> text <> "\ESC[0m"
grey Line NotTTY text = " " <> text
grey _ NotTTY text = text
-- | Format the left half of a diff
minus :: TTY -> Text -> Text
minus tty text = red tty ("- " <> text)
-- | Format the right half of a diff
plus :: TTY -> Text -> Text
plus tty text = green tty ("+ " <> text)
-- | Format text explaining a diff
explain :: Text -> Text
explain text = "• " <> text
-- `getGroupedDiff` from `Diff` library, adapted for `patience`
getGroupedDiff :: Ord a => [a] -> [a] -> [Patience.Item [a]]
getGroupedDiff oldList newList = go $ Patience.diff oldList newList
where
go = \case
Patience.Old x : xs ->
let (fs, rest) = goOlds xs
in Patience.Old (x : fs) : go rest
Patience.New x : xs ->
let (fs, rest) = goNews xs
in Patience.New (x : fs) : go rest
Patience.Both x y : xs ->
let (fs, rest) = goBoth xs
(fxs, fys) = unzip fs
in Patience.Both (x : fxs) (y : fys) : go rest
[] -> []
goOlds = \case
Patience.Old x : xs ->
let (fs, rest) = goOlds xs
in (x : fs, rest)
xs -> ([], xs)
goNews = \case
Patience.New x : xs ->
let (fs, rest) = goNews xs
in (x : fs, rest)
xs -> ([], xs)
goBoth = \case
Patience.Both x y : xs ->
let (fs, rest) = goBoth xs
in ((x, y) : fs, rest)
xs -> ([], xs)
{-| Utility to automate a common pattern of printing the two halves of a diff.
This passes the correct formatting function to each half
-}
diffWith :: a -> a -> ((Text -> Text, a) -> Diff ()) -> Diff ()
diffWith l r k = do
Context { tty } <- ask
k (minus tty, l)
k (plus tty, r)
-- | Format the derivation outputs
renderOutputs :: Set Text -> Text
renderOutputs outputs =
":{" <> Data.Text.intercalate "," (Data.Set.toList outputs) <> "}"
-- | Diff two outputs
diffOutput
:: Text
-- ^ Output name
-> (DerivationOutput FilePath Text)
-- ^ Left derivation outputs
-> (DerivationOutput FilePath Text)
-- ^ Right derivation outputs
-> Diff ()
diffOutput outputName leftOutput rightOutput = do
-- We deliberately do not include output paths or hashes in the diff since
-- we already expect them to differ if the inputs differ. Instead, we focus
-- only displaying differing inputs.
let leftHashAlgo = Nix.Derivation.hashAlgo leftOutput
let rightHashAlgo = Nix.Derivation.hashAlgo rightOutput
if leftHashAlgo == rightHashAlgo
then return ()
else do
echo (explain ("{" <> outputName <> "}:"))
echo (explain " Hash algorithm:")
diffWith leftHashAlgo rightHashAlgo \(sign, hashAlgo) -> do
echo (" " <> sign hashAlgo)
-- | Diff two sets of outputs
diffOutputs
:: Map Text (DerivationOutput FilePath Text)
-- ^ Left derivation outputs
-> Map Text (DerivationOutput FilePath Text)
-- ^ Right derivation outputs
-> Diff ()
diffOutputs leftOutputs rightOutputs = do
let leftExtraOutputs = Data.Map.difference leftOutputs rightOutputs
let rightExtraOutputs = Data.Map.difference rightOutputs leftOutputs
let bothOutputs = innerJoin leftOutputs rightOutputs
if Data.Map.null leftExtraOutputs && Data.Map.null rightExtraOutputs
then return ()
else do
echo (explain "The set of outputs do not match:")
diffWith leftExtraOutputs rightExtraOutputs \(sign, extraOutputs) -> do
forM_ (Data.Map.toList extraOutputs) \(key, _value) -> do
echo (" " <> sign ("{" <> key <> "}"))
forM_ (Data.Map.toList bothOutputs) \(key, (leftOutput, rightOutput)) -> do
if leftOutput == rightOutput
then return ()
else diffOutput key leftOutput rightOutput
mapDiff :: (a -> b) -> Patience.Item a -> Patience.Item b
mapDiff f (Patience.Old l ) = Patience.Old (f l)
mapDiff f (Patience.New r) = Patience.New (f r)
mapDiff f (Patience.Both l r) = Patience.Both (f l) (f r)
{-| Split `Text` into spans of `Text` that alternatively fail and satisfy the
given predicate
The first span (if present) does not satisfy the predicate (even if the
span is empty)
>>> decomposeOn (== 'b') "aabbaa"
["aa","bb","aa"]
>>> decomposeOn (== 'b') "bbaa"
["","bb","aa"]
>>> decomposeOn (== 'b') ""
[]
-}
decomposeOn :: (Char -> Bool) -> Text -> [Text]
decomposeOn predicate = unsatisfy
where
unsatisfy text
| Data.Text.null text = []
| otherwise = prefix : satisfy suffix
where
(prefix, suffix) = Data.Text.break predicate text
satisfy text
| Data.Text.null text = []
| otherwise = prefix : unsatisfy suffix
where
(prefix, suffix) = Data.Text.span predicate text
lineBoundary :: Char -> Bool
lineBoundary = ('\n' ==)
wordBoundary :: Char -> Bool
wordBoundary = Char.isSpace
-- | Diff two `Text` values
diffText
:: Text
-- ^ Left value to compare
-> Text
-- ^ Right value to compare
-> Diff Text
diffText left right = do
Context{ indent, orientation, tty } <- ask
let n = fromIntegral indent
let leftString = Data.Text.unpack left
let rightString = Data.Text.unpack right
let decomposeWords = decomposeOn wordBoundary
let decomposeLines text = loop (decomposeOn lineBoundary text)
where
-- Groups each newline character with the preceding line
loop (x : y : zs) = (x <> y) : loop zs
loop zs = zs
let leftWords = decomposeWords left
let rightWords = decomposeWords right
let leftLines = decomposeLines left
let rightLines = decomposeLines right
let chunks =
case orientation of
Character ->
fmap (mapDiff Data.Text.pack) (getGroupedDiff leftString rightString)
Word ->
Patience.diff leftWords rightWords
Line ->
Patience.diff leftLines rightLines
let prefix = Data.Text.replicate n " "
let format text =
if 80 <= n + Data.Text.length text
then "''\n" <> indentedText <> prefix <> "''"
else text
where
indentedText =
(Data.Text.unlines . fmap indentLine . Data.Text.lines) text
where
indentLine line = prefix <> " " <> line
let renderChunk (Patience.Old l ) =
redBackground orientation tty l
renderChunk (Patience.New r) =
greenBackground orientation tty r
renderChunk (Patience.Both l _) =
grey orientation tty l
return (format (Data.Text.concat (fmap renderChunk chunks)))
-- | Diff two environments
diffEnv
:: Set Text
-- ^ Left derivation outputs
-> Set Text
-- ^ Right derivation outputs
-> Map Text Text
-- ^ Left environment to compare
-> Map Text Text
-- ^ Right environment to compare
-> Diff ()
diffEnv leftOutputs rightOutputs leftEnv rightEnv = do
let leftExtraEnv = Data.Map.difference leftEnv rightEnv
let rightExtraEnv = Data.Map.difference rightEnv leftEnv
let bothEnv = innerJoin leftEnv rightEnv
let predicate key (left, right) =
left == right
|| ( Data.Set.member key leftOutputs
&& Data.Set.member key rightOutputs
)
|| key == "builder"
|| key == "system"
if Data.Map.null leftExtraEnv
&& Data.Map.null rightExtraEnv
&& Data.Map.null
(Data.Map.filterWithKey (\k v -> not (predicate k v)) bothEnv)
then return ()
else do
echo (explain "The environments do not match:")
diffWith leftExtraEnv rightExtraEnv \(sign, extraEnv) -> do
forM_ (Data.Map.toList extraEnv) \(key, value) -> do
echo (" " <> sign (key <> "=" <> value))
forM_ (Data.Map.toList bothEnv) \(key, (leftValue, rightValue)) -> do
if predicate key (leftValue, rightValue)
then return ()
else do
text <- diffText leftValue rightValue
echo (" " <> key <> "=" <> text)
-- | Diff input sources
diffSrcs
:: Set FilePath
-- ^ Left input sources
-> Set FilePath
-- ^ Right inputSources
-> Diff ()
diffSrcs leftSrcs rightSrcs = do
let groupedLeftSrcs = groupSetsByName leftSrcs
let groupedRightSrcs = groupSetsByName rightSrcs
let leftNames = Data.Map.keysSet groupedLeftSrcs
let rightNames = Data.Map.keysSet groupedRightSrcs
let leftExtraNames = Data.Set.difference leftNames rightNames
let rightExtraNames = Data.Set.difference rightNames leftNames
let leftExtraSrcs = Data.Set.difference leftSrcs rightSrcs
let rightExtraSrcs = Data.Set.difference rightSrcs leftSrcs
Monad.when (leftNames /= rightNames) do
echo (explain "The set of input source names do not match:")
diffWith leftExtraNames rightExtraNames \(sign, names) -> do
forM_ names \name -> do
echo (" " <> sign name)
let assocs = Data.Map.toList (innerJoin groupedLeftSrcs groupedRightSrcs)
forM_ assocs \(inputName, (leftPaths, rightPaths)) -> do
let leftExtraPaths = Data.Set.difference leftPaths rightPaths
let rightExtraPaths = Data.Set.difference rightPaths leftPaths
case (Data.Set.toList leftExtraPaths, Data.Set.toList rightExtraPaths) of
([], []) -> return ()
([leftPath], [rightPath]) -> do
echo (explain ("The input source named `" <> inputName <> "` differs"))
leftExists <- liftIO (Directory.doesFileExist leftPath)
rightExists <- liftIO (Directory.doesFileExist rightPath)
if leftExists && rightExists
then do
leftText <- liftIO (Data.Text.IO.readFile leftPath)
rightText <- liftIO (Data.Text.IO.readFile rightPath)
text <- diffText leftText rightText
echo (" " <> text)
else do
return ()
return ()
diffPlatform :: Text -> Text -> Diff ()
diffPlatform leftPlatform rightPlatform = do
if leftPlatform == rightPlatform
then return ()
else do
echo (explain "The platforms do not match")
diffWith leftPlatform rightPlatform \(sign, platform) -> do
echo (" " <> sign platform)
diffBuilder :: Text -> Text -> Diff ()
diffBuilder leftBuilder rightBuilder = do
if leftBuilder == rightBuilder
then return ()
else do
echo (explain "The builders do not match")
diffWith leftBuilder rightBuilder \(sign, builder) -> do
echo (" " <> sign builder)
diffArgs :: Vector Text -> Vector Text -> Diff ()
diffArgs leftArgs rightArgs = do
Context { tty } <- ask
if leftArgs == rightArgs
then return ()
else do
echo (explain "The arguments do not match")
let leftList = Data.Vector.toList leftArgs
let rightList = Data.Vector.toList rightArgs
let diffs = Patience.diff leftList rightList
let renderDiff (Patience.Old arg) =
echo (" " <> minus tty arg)
renderDiff (Patience.New arg) =
echo (" " <> plus tty arg)
renderDiff (Patience.Both arg _) =
echo (" " <> explain arg)
mapM_ renderDiff diffs
diff :: Bool -> FilePath -> Set Text -> FilePath -> Set Text -> Diff ()
diff topLevel leftPath leftOutputs rightPath rightOutputs = do
Status { visited } <- get
let diffed = Diffed leftPath leftOutputs rightPath rightOutputs
if leftPath == rightPath
then return ()
else if Data.Set.member diffed visited
then do
echo (explain "These two derivations have already been compared")
else do
put (Status (Data.Set.insert diffed visited))
diffWith (leftPath, leftOutputs) (rightPath, rightOutputs) \(sign, (path, outputs)) -> do
echo (sign (pathToText path <> renderOutputs outputs))
if derivationName leftPath /= derivationName rightPath && not topLevel
then do
echo (explain "The derivation names do not match")
else if leftOutputs /= rightOutputs
then do
echo (explain "The requested outputs do not match")
else do
leftDerivation <- readDerivation leftPath
rightDerivation <- readDerivation rightPath
let leftOuts = Nix.Derivation.outputs leftDerivation
let rightOuts = Nix.Derivation.outputs rightDerivation
diffOutputs leftOuts rightOuts
let leftPlatform = Nix.Derivation.platform leftDerivation
let rightPlatform = Nix.Derivation.platform rightDerivation
diffPlatform leftPlatform rightPlatform
let leftBuilder = Nix.Derivation.builder leftDerivation
let rightBuilder = Nix.Derivation.builder rightDerivation
diffBuilder leftBuilder rightBuilder
let leftArgs = Nix.Derivation.args leftDerivation
let rightArgs = Nix.Derivation.args rightDerivation
diffArgs leftArgs rightArgs
let leftSrcs = Nix.Derivation.inputSrcs leftDerivation
let rightSrcs = Nix.Derivation.inputSrcs rightDerivation
diffSrcs leftSrcs rightSrcs
let leftInputs = groupByName (Nix.Derivation.inputDrvs leftDerivation)
let rightInputs = groupByName (Nix.Derivation.inputDrvs rightDerivation)
let leftNames = Data.Map.keysSet leftInputs
let rightNames = Data.Map.keysSet rightInputs
let leftExtraNames = Data.Set.difference leftNames rightNames
let rightExtraNames = Data.Set.difference rightNames leftNames
Monad.when (leftNames /= rightNames) do
echo (explain "The set of input derivation names do not match:")
diffWith leftExtraNames rightExtraNames \(sign, names) -> do
forM_ names \name -> do
echo (" " <> sign name)
let assocs = Data.Map.toList (innerJoin leftInputs rightInputs)
descended <- forM assocs \(inputName, (leftPaths, rightPaths)) -> do
let leftExtraPaths =
Data.Map.difference leftPaths rightPaths
let rightExtraPaths =
Data.Map.difference rightPaths leftPaths
case (Data.Map.toList leftExtraPaths, Data.Map.toList rightExtraPaths) of
_ | leftPaths == rightPaths -> do
return False
([(leftPath', leftOutputs')], [(rightPath', rightOutputs')])
| leftOutputs' == rightOutputs' -> do
echo (explain ("The input derivation named `" <> inputName <> "` differs"))
indented 2 (diff False leftPath' leftOutputs' rightPath' rightOutputs')
return True
_ -> do
echo (explain ("The set of input derivations named `" <> inputName <> "` do not match"))
diffWith leftExtraPaths rightExtraPaths \(sign, extraPaths) -> do
forM_ (Data.Map.toList extraPaths) \(extraPath, outputs) -> do
echo (" " <> sign (pathToText extraPath <> renderOutputs outputs))
return False
Context { environment } <- ask
if or descended && not environment
then do
echo (explain "Skipping environment comparison")
else do
let leftEnv = Nix.Derivation.env leftDerivation
let rightEnv = Nix.Derivation.env rightDerivation
let leftOutNames = Data.Map.keysSet leftOuts
let rightOutNames = Data.Map.keysSet rightOuts
diffEnv leftOutNames rightOutNames leftEnv rightEnv
main :: IO ()
main = do
GHC.IO.Encoding.setLocaleEncoding GHC.IO.Encoding.utf8
Options { left, right, color, orientation, environment } <- Options.Applicative.execParser parserInfo
tty <- case color of
Never -> do
return NotTTY
Always -> do
return IsTTY
Auto -> do
b <- System.Posix.Terminal.queryTerminal System.Posix.IO.stdOutput
return (if b then IsTTY else NotTTY)
let indent = 0
let context = Context { tty, indent, orientation, environment }
let status = Status Data.Set.empty
let action = diff True left (Data.Set.singleton "out") right (Data.Set.singleton "out")
Control.Monad.State.evalStateT (Control.Monad.Reader.runReaderT (unDiff action) context) status