| 1 | {-# LANGUAGE CPP #-} |
| 2 | {-# LANGUAGE LambdaCase #-} |
| 3 | {-# LANGUAGE OverloadedStrings #-} |
| 4 | {-# LANGUAGE QualifiedDo #-} |
| 5 | {-# LANGUAGE RecordWildCards #-} |
| 6 | |
| 7 | -- | This module allows us to diff two 'Text' values. |
| 8 | module Ormolu.Diff.Text |
| 9 | ( TextDiff, |
| 10 | diffText, |
| 11 | selectSpans, |
| 12 | printTextDiff, |
| 13 | ) |
| 14 | where |
| 15 | |
| 16 | import Control.Monad (unless, when) |
| 17 | import Data.Algorithm.Diff qualified as D |
| 18 | import Data.Foldable (for_) |
| 19 | import Data.IntSet (IntSet) |
| 20 | import Data.IntSet qualified as IntSet |
| 21 | import Data.Maybe (listToMaybe) |
| 22 | import Data.Text (Text) |
| 23 | import Data.Text qualified as T |
| 24 | import GHC.Types.SrcLoc |
| 25 | import Ormolu.Terminal |
| 26 | import Ormolu.Terminal.QualifiedDo qualified as Term |
| 27 | #if !MIN_VERSION_base(4,20,0) |
| 28 | import Data.List (foldl') |
| 29 | #endif |
| 30 | |
| 31 | ---------------------------------------------------------------------------- |
| 32 | -- Types |
| 33 | |
| 34 | -- | Result of diffing two 'Text's. |
| 35 | data TextDiff = TextDiff |
| 36 | { -- | Path to the file that is being diffed |
| 37 | textDiffPath :: FilePath, |
| 38 | -- | The list of differences |
| 39 | textDiffDiffList :: DiffList, |
| 40 | -- | Selected lines. Only hunks that contain selected lines will be |
| 41 | -- displayed, unless 'textDiffSelectedLines' is empty, in which case the |
| 42 | -- whole diff will be displayed. |
| 43 | textDiffSelectedLines :: IntSet |
| 44 | } |
| 45 | deriving (Eq, Show) |
| 46 | |
| 47 | -- | List of lines tagged by 'D.Both', 'D.First', or 'D.Second'. |
| 48 | type DiffList = [D.Diff [Text]] |
| 49 | |
| 50 | -- | Similar to 'DiffList', but with line numbers assigned. |
| 51 | type DiffList' = [D.Diff [(Int, Int, Text)]] |
| 52 | |
| 53 | -- | Diff hunk. |
| 54 | data Hunk = Hunk |
| 55 | { hunkFirstStartLine :: Int, |
| 56 | hunkFirstLength :: Int, |
| 57 | hunkSecondStartLine :: Int, |
| 58 | hunkSecondLength :: Int, |
| 59 | hunkDiff :: DiffList |
| 60 | } |
| 61 | deriving (Show) |
| 62 | |
| 63 | ---------------------------------------------------------------------------- |
| 64 | -- API |
| 65 | |
| 66 | -- | Diff two texts and produce a 'TextDiff'. |
| 67 | diffText :: |
| 68 | -- | Text before |
| 69 | Text -> |
| 70 | -- | Text after |
| 71 | Text -> |
| 72 | -- | Path to use |
| 73 | FilePath -> |
| 74 | -- | The resulting diff or 'Nothing' if the inputs are identical |
| 75 | Maybe TextDiff |
| 76 | diffText a b path = |
| 77 | if all isBoth xs |
| 78 | then Nothing |
| 79 | else |
| 80 | Just |
| 81 | TextDiff |
| 82 | { textDiffPath = path, |
| 83 | textDiffDiffList = xs, |
| 84 | textDiffSelectedLines = IntSet.empty |
| 85 | } |
| 86 | where |
| 87 | xs = D.getGroupedDiff (lines' a) (lines' b) |
| 88 | isBoth = \case |
| 89 | D.Both _ _ -> True |
| 90 | D.First _ -> False |
| 91 | D.Second _ -> False |
| 92 | -- T.lines ignores trailing blank lines |
| 93 | lines' = T.splitOn "\n" |
| 94 | |
| 95 | -- | Select certain spans in the diff (line numbers are interpreted as |
| 96 | -- belonging to the “before” state). Only selected spans will be printed. |
| 97 | selectSpans :: [RealSrcSpan] -> TextDiff -> TextDiff |
| 98 | selectSpans ss textDiff = textDiff {textDiffSelectedLines = xs} |
| 99 | where |
| 100 | xs = foldl' addOneSpan (textDiffSelectedLines textDiff) ss |
| 101 | addOneSpan linesSoFar s = |
| 102 | let start = srcSpanStartLine s |
| 103 | end = srcSpanEndLine s |
| 104 | in IntSet.union |
| 105 | linesSoFar |
| 106 | (IntSet.fromAscList [start .. end]) |
| 107 | |
| 108 | -- | Print the given 'TextDiff' as a 'Term' action. This function tries to |
| 109 | -- mimic the style of @git diff@. |
| 110 | printTextDiff :: TextDiff -> Term |
| 111 | printTextDiff TextDiff {..} = Term.do |
| 112 | (bold . put . T.pack) textDiffPath |
| 113 | newline |
| 114 | for_ (toHunks (assignLines textDiffDiffList)) $ \hunk@Hunk {..} -> |
| 115 | when (isSelectedLine textDiffSelectedLines hunk) $ Term.do |
| 116 | cyan $ Term.do |
| 117 | put "@@ -" |
| 118 | putShow hunkFirstStartLine |
| 119 | put "," |
| 120 | putShow hunkFirstLength |
| 121 | put " +" |
| 122 | putShow hunkSecondStartLine |
| 123 | put "," |
| 124 | putShow hunkSecondLength |
| 125 | put " @@" |
| 126 | newline |
| 127 | for_ hunkDiff $ \case |
| 128 | D.Both ys _ -> |
| 129 | for_ ys $ \y -> Term.do |
| 130 | unless (T.null y) $ |
| 131 | put " " |
| 132 | put y |
| 133 | newline |
| 134 | D.First ys -> |
| 135 | for_ ys $ \y -> red $ Term.do |
| 136 | put "-" |
| 137 | unless (T.null y) $ |
| 138 | put " " |
| 139 | put y |
| 140 | newline |
| 141 | D.Second ys -> |
| 142 | for_ ys $ \y -> green $ Term.do |
| 143 | put "+" |
| 144 | unless (T.null y) $ |
| 145 | put " " |
| 146 | put y |
| 147 | newline |
| 148 | |
| 149 | ---------------------------------------------------------------------------- |
| 150 | -- Helpers |
| 151 | |
| 152 | -- | Assign lines to a 'DiffList'. |
| 153 | assignLines :: DiffList -> DiffList' |
| 154 | assignLines = go 1 1 id |
| 155 | where |
| 156 | go _ _ acc [] = acc [] |
| 157 | go !firstLine !secondLine acc (x : xs) = |
| 158 | case x of |
| 159 | D.Both a b -> |
| 160 | let firstInc = length a |
| 161 | secondInc = length b |
| 162 | a' = |
| 163 | zip3 |
| 164 | (iterate (+ 1) firstLine) |
| 165 | (iterate (+ 1) secondLine) |
| 166 | a |
| 167 | in go |
| 168 | (firstLine + firstInc) |
| 169 | (secondLine + secondInc) |
| 170 | (acc . (D.Both a' a' :)) |
| 171 | xs |
| 172 | D.First a -> |
| 173 | let firstInc = length a |
| 174 | a' = |
| 175 | zip3 |
| 176 | (iterate (+ 1) firstLine) |
| 177 | (repeat secondLine) |
| 178 | a |
| 179 | in go |
| 180 | (firstLine + firstInc) |
| 181 | secondLine |
| 182 | (acc . (D.First a' :)) |
| 183 | xs |
| 184 | D.Second b -> |
| 185 | let secondInc = length b |
| 186 | b' = |
| 187 | zip3 |
| 188 | (repeat firstLine) |
| 189 | (iterate (+ 1) secondLine) |
| 190 | b |
| 191 | in go |
| 192 | firstLine |
| 193 | (secondLine + secondInc) |
| 194 | (acc . (D.Second b' :)) |
| 195 | xs |
| 196 | |
| 197 | -- | Form 'Hunk's from a 'DiffList''. |
| 198 | toHunks :: DiffList' -> [Hunk] |
| 199 | toHunks = go 0 False id id [] |
| 200 | where |
| 201 | -- How many lines of context (that is, lines present in both texts) to |
| 202 | -- show per hunk. |
| 203 | margin = 3 |
| 204 | go :: |
| 205 | Int -> |
| 206 | Bool -> |
| 207 | ([Hunk] -> [Hunk]) -> |
| 208 | (DiffList' -> DiffList') -> |
| 209 | [(Int, Int, Text)] -> |
| 210 | DiffList' -> |
| 211 | [Hunk] |
| 212 | go !n gotChanges hunksAcc currentAcc bothHistory = \case |
| 213 | [] -> |
| 214 | if gotChanges |
| 215 | then |
| 216 | let currentAcc' = addBothAfter p currentAcc |
| 217 | p = take margin (reverse bothHistory) |
| 218 | in case formHunk (currentAcc' []) of |
| 219 | Nothing -> hunksAcc [] |
| 220 | Just hunk -> hunksAcc [hunk] |
| 221 | else hunksAcc [] |
| 222 | (x : xs) -> |
| 223 | case x of |
| 224 | D.Both a _ -> |
| 225 | let currentAcc' = addBothAfter p currentAcc |
| 226 | p = reverse (drop (n' - margin) bothHistory') |
| 227 | hunksAcc' = |
| 228 | case formHunk (currentAcc' []) of |
| 229 | Nothing -> hunksAcc |
| 230 | Just hunk -> hunksAcc . (hunk :) |
| 231 | bothHistory' = reverse a ++ bothHistory |
| 232 | lena = length a |
| 233 | n' = n + lena |
| 234 | in if gotChanges && n' > margin * 2 |
| 235 | then go 0 False hunksAcc' id bothHistory' xs |
| 236 | else go n' gotChanges hunksAcc currentAcc bothHistory' xs |
| 237 | piece -> |
| 238 | if gotChanges |
| 239 | then |
| 240 | let currentAcc' = currentAcc . addBothBefore p (piece :) |
| 241 | p = reverse bothHistory |
| 242 | in go 0 True hunksAcc currentAcc' [] xs |
| 243 | else |
| 244 | let currentAcc' = addBothBefore p (piece :) |
| 245 | p = reverse (take margin bothHistory) |
| 246 | in go 0 True hunksAcc currentAcc' [] xs |
| 247 | addBothBefore [] acc = acc |
| 248 | addBothBefore p acc = (D.Both p p :) . acc |
| 249 | addBothAfter [] acc = acc |
| 250 | addBothAfter p acc = acc . (D.Both p p :) |
| 251 | |
| 252 | -- | Form a 'Hunk'. |
| 253 | formHunk :: DiffList' -> Maybe Hunk |
| 254 | formHunk xsRaw = do |
| 255 | let xs = trimEmpty xsRaw |
| 256 | hunkFirstStartLine <- listToMaybe xs >>= firstStartLine |
| 257 | let hunkFirstLength = firstLength xs |
| 258 | hunkSecondStartLine <- listToMaybe xs >>= secondStartLine |
| 259 | let hunkSecondLength = secondLength xs |
| 260 | hunkDiff = mapDiff (fmap third) xs |
| 261 | return Hunk {..} |
| 262 | |
| 263 | -- | Trim empty “both” lines from beginning and end of a 'DiffList''. |
| 264 | trimEmpty :: DiffList' -> DiffList' |
| 265 | trimEmpty = go True id |
| 266 | where |
| 267 | go isFirst acc = \case |
| 268 | [] -> acc [] |
| 269 | [D.Both x _] -> |
| 270 | let x' = reverse $ dropWhile (T.null . third) (reverse x) |
| 271 | in go False (acc . (D.Both x' x' :)) [] |
| 272 | (D.Both x _ : xs) -> |
| 273 | let x' = dropWhile (T.null . third) x |
| 274 | in if isFirst |
| 275 | then go False (acc . (D.Both x' x' :)) xs |
| 276 | else go False (acc . (D.Both x x :)) xs |
| 277 | (x : xs) -> |
| 278 | go False (acc . (x :)) xs |
| 279 | |
| 280 | firstStartLine :: D.Diff [(Int, Int, a)] -> Maybe Int |
| 281 | firstStartLine = \case |
| 282 | D.Both ((x, _, _) : _) _ -> Just x |
| 283 | D.First ((x, _, _) : _) -> Just x |
| 284 | D.Second ((x, _, _) : _) -> Just x |
| 285 | _ -> Nothing |
| 286 | |
| 287 | firstLength :: [D.Diff [(Int, Int, a)]] -> Int |
| 288 | firstLength = go 0 |
| 289 | where |
| 290 | go n [] = n |
| 291 | go !n (x : xs) = case x of |
| 292 | D.Both as _ -> go (n + length as) xs |
| 293 | D.First as -> go (n + length as) xs |
| 294 | D.Second _ -> go n xs |
| 295 | |
| 296 | secondStartLine :: D.Diff [(Int, Int, a)] -> Maybe Int |
| 297 | secondStartLine = \case |
| 298 | D.Both ((_, x, _) : _) _ -> Just x |
| 299 | D.First ((_, x, _) : _) -> Just x |
| 300 | D.Second ((_, x, _) : _) -> Just x |
| 301 | _ -> Nothing |
| 302 | |
| 303 | secondLength :: [D.Diff [(Int, Int, a)]] -> Int |
| 304 | secondLength = go 0 |
| 305 | where |
| 306 | go n [] = n |
| 307 | go !n (x : xs) = case x of |
| 308 | D.Both as _ -> go (n + length as) xs |
| 309 | D.First _ -> go n xs |
| 310 | D.Second as -> go (n + length as) xs |
| 311 | |
| 312 | mapDiff :: (a -> b) -> [D.Diff a] -> [D.Diff b] |
| 313 | mapDiff f = fmap $ \case |
| 314 | D.Both a b -> D.Both (f a) (f b) |
| 315 | D.First a -> D.First (f a) |
| 316 | D.Second b -> D.Second (f b) |
| 317 | |
| 318 | third :: (Int, Int, Text) -> Text |
| 319 | third (_, _, x) = x |
| 320 | |
| 321 | isSelectedLine :: IntSet -> Hunk -> Bool |
| 322 | isSelectedLine selected Hunk {..} = |
| 323 | -- If the set of selected lines is empty, everything is selected. |
| 324 | IntSet.null selected |
| 325 | || not (IntSet.disjoint selected hunkOriginalLines) |
| 326 | where |
| 327 | hunkOriginalLines = |
| 328 | IntSet.fromAscList (take hunkFirstLength [hunkFirstStartLine ..]) |