packages feed

dojang-0.2.0: src/Dojang/Commands/Diff.hs

{-# LANGUAGE ImportQualifiedPost #-}
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}

module Dojang.Commands.Diff (DiffMode (..), diff) where

import Control.Monad (filterM, forM, forM_, unless, when)
import Control.Monad.IO.Class (MonadIO (liftIO))
import Data.List (find, nub)
import Data.Maybe (maybeToList)
import System.Environment (lookupEnv)
import System.Exit (ExitCode (ExitFailure, ExitSuccess), exitWith)
import System.IO (stderr, stdout)
import System.Info (os)
import Prelude hiding (lines, putStrLn, readFile)
import Prelude qualified (putStrLn)

import Data.Algorithm.Diff (getGroupedDiff)
import Data.Algorithm.DiffOutput
  ( DiffOperation (..)
  , LineRange (..)
  , diffToLineRanges
  )
import Data.Text (Text, cons, lines, pack, unpack)
import Data.Text.Encoding (decodeUtf8)
import Data.Text.IO (putStrLn)
import System.Directory.OsPath (makeAbsolute)
import System.OsPath (OsPath)
import System.Process (spawnProcess, waitForProcess)
import TextShow (TextShow (showt))

import Dojang.App (App, ensureContext)
import Dojang.Commands
  ( Admonition (..)
  , Color (..)
  , colorFor
  , die'
  , pathStyleFor
  , pathStyleFor'
  , printStderr'
  )
import Dojang.Commands.Status (printWarnings)
import Dojang.ExitCodes (externalProgramNonZeroExit, fileNotRoutedError)
import Dojang.MonadFileSystem (MonadFileSystem (..))
import Dojang.Types.Context
  ( FileCorrespondence (..)
  , FileDeltaKind (..)
  , FileEntry (..)
  , FileStat (..)
  , makeCorrespond
  )


data DiffMode = Both | Source | Destination deriving (Show)


diff
  :: (MonadFileSystem i, MonadIO i)
  => DiffMode
  -> Maybe OsPath
  -> [OsPath]
  -> App i ExitCode
diff mode diffProgram files = do
  ctx <- ensureContext
  (corresponds, ws) <- makeCorrespond ctx
  routedFiles <- forM corresponds $ \c -> do
    sourcePath <- liftIO $ makeAbsolute c.source.path
    destinationPath <- liftIO $ makeAbsolute c.destination.path
    return (sourcePath, destinationPath)
  pathStyle <- pathStyleFor stderr
  nonExistents <- (`filterM` files) $ \file -> do
    file' <- liftIO $ makeAbsolute file
    if any (\(src, dst) -> file' == src || file' == dst) routedFiles
      then return False
      else do
        printWarnings ws
        printStderr' Error $ pathStyle file <> " is not a routed file."
        return True
  unless (null nonExistents) $ do
    printWarnings ws
    liftIO $ exitWith fileNotRoutedError
  files' <-
    nub <$> case files of
      [] -> return corresponds
      _ -> do
        fs <- forM files $ \file -> do
          file' <- liftIO $ makeAbsolute file
          let found =
                find (\((src, dst), _) -> file' == src || file' == dst) $
                  zip routedFiles corresponds
          return $ maybeToList $ fmap snd found
        return $ concat fs
  case mode of
    Source -> twoWay diffProgram (.source) (.intermediate) files'
    Destination -> twoWay diffProgram (.destination) (.intermediate) files'
    Both -> twoWay diffProgram (.source) (.destination) files'
  printWarnings ws
  return ExitSuccess


nullPath :: FilePath
nullPath = if os == "mingw32" then "NUL" else "/dev/null"


getPath :: (MonadFileSystem m) => FileEntry -> m FilePath
getPath entry = case entry.stat of
  Missing -> return nullPath
  Directory -> return nullPath
  _ -> decodePath entry.path


twoWay
  :: (MonadFileSystem i, MonadIO i)
  => Maybe OsPath
  -> (FileCorrespondence -> FileEntry)
  -> (FileCorrespondence -> FileEntry)
  -> [FileCorrespondence]
  -> App i ()
twoWay program' srcSelector dstSelector files = do
  program <- case program' of
    Just p -> Just <$> decodePath p
    Nothing -> liftIO $ lookupEnv "DOJANG_DIFF"
  forM_ files $ \file -> when (hasChange file) $ do
    let src = srcSelector file
    let dst = dstSelector file
    case program of
      Nothing -> do
        builtinDiff src dst
      Just prog -> do
        srcPath <- getPath src
        dstPath <- getPath dst
        handle <- liftIO $ spawnProcess prog [srcPath, dstPath]
        exitCode <- liftIO $ waitForProcess handle
        when (exitCode /= ExitSuccess && exitCode /= ExitFailure 1) $ do
          cmdStyle <- pathStyleFor' stderr
          die' externalProgramNonZeroExit $
            cmdStyle (pack prog)
              <> " terminated with exit code "
              <> showt exitCode
              <> "."


hasChange :: FileCorrespondence -> Bool
hasChange c = case (c.sourceDelta, c.destinationDelta) of
  (Unchanged, Unchanged) -> False
  _ -> True


builtinDiff
  :: forall i. (MonadFileSystem i, MonadIO i) => FileEntry -> FileEntry -> App i ()
builtinDiff a b = do
  color <- colorFor stdout
  color' <- colorFor stdout
  pathStyle <- pathStyleFor stdout
  liftIO $
    putStrLn $
      color Default Red "--- " <> pathStyle a.path <> case a.stat of
        Missing -> " (missing)"
        Directory -> " (directory)"
        _ -> ""
  liftIO $
    putStrLn $
      color Default Green "+++ " <> pathStyle b.path <> case b.stat of
        Missing -> " (missing)"
        Directory -> " (directory)"
        _ -> ""
  textA <- readFileEntry a
  textB <- readFileEntry b
  let linesA = lines textA
  let linesB = lines textB
  let diffOps =
        diffToLineRanges $
          getGroupedDiff (unpack <$> linesA) (unpack <$> linesB)
  forM_ diffOps $ \op -> liftIO $ do
    case op of
      Deletion aRange bOffset -> do
        let ( addRange
              , delRange
              , (headOffset, headLines)
              , (tailOffset, tailLines)
              ) = calcRange linesB (bOffset + 1, bOffset) linesA aRange.lrNumbers
        printRange delRange addRange
        forM_ (take headLines (drop headOffset linesB)) $ \line ->
          putStrLn $ ' ' `cons` line
        forM_ aRange.lrContents $ \line ->
          Prelude.putStrLn $ color' Default Red ('-' : line)
        forM_ (take tailLines (drop tailOffset linesB)) $ \line ->
          putStrLn $ ' ' `cons` line
      Addition bRange aOffset -> do
        let ( addRange
              , delRange
              , (headOffset, headLines)
              , (tailOffset, tailLines)
              ) = calcRange linesB bRange.lrNumbers linesA (aOffset + 1, aOffset)
        printRange delRange addRange
        forM_ (take headLines (drop headOffset linesB)) $ \line ->
          putStrLn $ ' ' `cons` line
        forM_ bRange.lrContents $ \line ->
          Prelude.putStrLn $ color' Default Green ('+' : line)
        forM_ (take tailLines (drop tailOffset linesB)) $ \line ->
          putStrLn $ ' ' `cons` line
      Change aRange bRange -> do
        let ( addRange
              , delRange
              , (headOffset, headLines)
              , (tailOffset, tailLines)
              ) = calcRange linesB bRange.lrNumbers linesA aRange.lrNumbers
        printRange delRange addRange
        forM_ (take headLines (drop headOffset linesB)) $ \line ->
          putStrLn $ ' ' `cons` line
        forM_ aRange.lrContents $ \line ->
          Prelude.putStrLn $ color' Default Red ('-' : line)
        forM_ bRange.lrContents $ \line ->
          Prelude.putStrLn $ color' Default Green ('+' : line)
        forM_ (take tailLines (drop tailOffset linesB)) $ \line ->
          putStrLn $ ' ' `cons` line
 where
  context :: Int
  context = 3
  calcRange
    :: [Text]
    -> (Int, Int)
    -> [Text]
    -> (Int, Int)
    -> ((Int, Int), (Int, Int), (Int, Int), (Int, Int))
  calcRange addContentLines (addBegin, addEnd) delContentLines (delBegin, delEnd) =
    let headOffset = max 0 (addBegin - 1 - context)
        headLines = min context (addBegin - 1 - headOffset)
        tailOffset = addEnd
        tailLines = min context (length addContentLines - tailOffset)
        addOffset =
          min (length addContentLines) (1 + max 0 (addBegin - 1 - context))
        addLines =
          min
            (length addContentLines)
            (addEnd - (addBegin - 1) + headLines + tailLines)
        delOffset =
          min (length delContentLines) (1 + max 0 (delBegin - 1 - context))
        delLines =
          min
            (length delContentLines)
            (delEnd - (delBegin - 1) + headLines + tailLines)
    in ( (addOffset, addLines)
       , (delOffset, delLines)
       , (headOffset, headLines)
       , (tailOffset, tailLines)
       )
  printRange :: (Int, Int) -> (Int, Int) -> IO ()
  printRange (delOffset, delLines) (addOffset, addLines) = do
    color <- colorFor stdout
    putStrLn $
      color Default Cyan $
        "@@ -"
          <> showt delOffset
          <> ","
          <> showt delLines
          <> " +"
          <> showt addOffset
          <> ","
          <> showt addLines
          <> " @@"
  readFileEntry :: FileEntry -> App i Text
  readFileEntry entry = case entry.stat of
    Missing -> return ""
    Directory -> return ""
    _ ->
      -- FIXME: It is assuming that the file is encoded in UTF-8, but it is not
      --        always the case:
      decodeUtf8 <$> readFile entry.path