tilia-0.1.0.0: src/Tilia/Diff.hs
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE MultiWayIf #-}
{-# LANGUAGE OverloadedStrings #-}
-- | Showing what changed.
module Tilia.Diff
( diff,
diffInFull,
Mark (..),
editScript,
)
where
import Control.Monad.ST (ST, runST)
import Data.Array.ST (STUArray, newArray, readArray, writeArray)
import Data.Array.Unboxed (Array, UArray, listArray, (!))
import Data.Map.Strict qualified as Map
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import Data.Text qualified as T
import Tilia.Palette (Color (..), Palette, paint)
-- | What a line of the comparison does.
data Mark = Context | Removed | Added
deriving (Eq, Show)
-- | A unified diff of two texts.
diff ::
Palette ->
-- | What to call the two sides.
(Text, Text) ->
-- | Before.
Text ->
-- | After.
Text ->
Text
diff palette = unified palette (Just roomFor) []
-- | The whole of a unified diff of one file against its formatted self,
-- headed the way @git diff@ heads one.
diffInFull ::
Palette ->
-- | The file, named as it was given on the command line.
FilePath ->
-- | What is in it.
Text ->
-- | What would be.
Text ->
Text
diffInFull palette path =
unified
palette
Nothing
[paint palette Place ("diff --git " <> before <> " " <> after)]
(before, after)
where
before = "a/" <> T.pack path
after = "b/" <> T.pack path
-- | One line of the comparison, with the number it has on each side.
data Line = Line !Mark !Int !Int !Text
-- | A unified diff, with whatever heading and limit the caller wants.
unified ::
Palette ->
-- | How many lines are worth printing, where there is a limit at all.
Maybe Int ->
-- | Whatever goes above the two file names.
[Text] ->
-- | What to call the two sides.
(Text, Text) ->
-- | Before.
Text ->
-- | After.
Text ->
Text
unified palette limit above (beforeName, afterName) before after
| null hunks,
before /= after =
"(the two differ only in how they end their lines)"
| null hunks =
"(the two are identical as text, so the difference is in something\
\ the text does not show)"
| otherwise = T.intercalate "\n" (above <> heading <> shown)
where
heading =
[ paint palette (Header Gone) ("--- " <> beforeName),
paint palette (Header New) ("+++ " <> afterName)
]
shown = case limit of
Just room | length body > room -> take room body <> [omitted room]
_ -> body
where
omitted room =
paint palette Meta $
"… and " <> T.pack (show (length body - room)) <> " more lines"
body = concatMap render hunks
render range = hunkHeading range : fmap line (slice range)
hunkHeading range =
paint palette Meta $
"@@ -"
<> span' beforeOf (countingBefore (slice range))
<> " +"
<> span' afterOf (countingAfter (slice range))
<> " @@"
where
span' which n = case slice range of
(l : _) -> T.pack (show (which l)) <> "," <> T.pack (show n)
[] -> "0,0"
line (Line mark _ _ text) = case mark of
Context -> paint palette Unchanged (" " <> text)
Removed -> paint palette Gone ("-" <> text)
Added -> paint palette New ("+" <> text)
slice (from, to) = take (to - from + 1) (drop from lines')
countingBefore = length . filter (\(Line m _ _ _) -> m /= Added)
countingAfter = length . filter (\(Line m _ _ _) -> m /= Removed)
beforeOf (Line _ b _ _) = b
afterOf (Line _ _ a _) = a
hunks = merge [(max 0 (i - margin), min (total - 1) (i + margin)) | i <- changed]
changed = [i | (i, Line m _ _ _) <- zip [0 ..] lines', m /= Context]
total = length lines'
lines' = tag (split before) (split after)
split = fmap withoutReturn . T.splitOn "\n"
withoutReturn l = fromMaybe l (T.stripSuffix "\r" l)
-- | How many unchanged lines to show either side of a change.
margin :: Int
margin = 3
-- | How many lines of diff are worth printing before it stops being read.
roomFor :: Int
roomFor = 60
-- | Join hunks that have grown into one another.
merge :: [(Int, Int)] -> [(Int, Int)]
merge = \case
((a, b) : (c, d) : rest)
| c <= b + 1 -> merge ((a, max b d) : rest)
| otherwise -> (a, b) : merge ((c, d) : rest)
xs -> xs
-- | Number the lines of a comparison on both sides at once.
tag :: [Text] -> [Text] -> [Line]
tag xs ys = go 1 1 (editScript xs ys)
where
before = listArray (1, length xs) xs :: Array Int Text
after = listArray (1, length ys) ys :: Array Int Text
go !b !a = \case
[] -> []
Context : ms -> Line Context b a (before ! b) : go (b + 1) (a + 1) ms
Removed : ms -> Line Removed b a (before ! b) : go (b + 1) a ms
Added : ms -> Line Added b a (after ! a) : go b (a + 1) ms
-- | A shortest edit script turning one sequence of lines into another, as
-- what each line of the comparison does, in order.
--
-- Myers' O(ND) algorithm in linear space: the middle of an edit script is
-- found by searching from both ends at once, and either side of it is
-- solved the same way.
editScript :: [Text] -> [Text] -> [Mark]
editScript xs ys = removalsFirst (go 0 n 0 m [])
where
n = length xs
m = length ys
(as, bs) = interned xs ys
go aLo aHi bLo bHi rest
| aLo < aHi,
bLo < bHi,
as ! aLo == bs ! bLo =
Context : go (aLo + 1) aHi (bLo + 1) bHi rest
| aLo < aHi,
bLo < bHi,
as ! (aHi - 1) == bs ! (bHi - 1) =
go aLo (aHi - 1) bLo (bHi - 1) (Context : rest)
| aLo == aHi = replicate (bHi - bLo) Added <> rest
| bLo == bHi = replicate (aHi - aLo) Removed <> rest
| Just (x, y) <- middle as bs (aLo, aHi) (bLo, bHi),
(x, y) /= (aLo, bLo),
(x, y) /= (aHi, bHi) =
go aLo x bLo y (go x aHi y bHi rest)
| otherwise = replicate (aHi - aLo) Removed <> replicate (bHi - bLo) Added <> rest
-- | Put the removals of every run of changes before its additions.
removalsFirst :: [Mark] -> [Mark]
removalsFirst = \case
[] -> []
Context : rest -> Context : removalsFirst rest
marks ->
let (changes, rest) = break (== Context) marks
in filter (== Removed) changes <> filter (== Added) changes <> removalsFirst rest
-- | Both sequences of lines as numbers, equal where the lines are.
interned :: [Text] -> [Text] -> (UArray Int Int, UArray Int Int)
interned xs ys = (numbered xs, numbered ys)
where
numbers = Map.fromList (zip (xs <> ys) [0 ..])
numbered ls = listArray (0, length ls - 1) (fmap (numbers Map.!) ls)
-- | A point that a shortest edit script between two ranges passes through,
-- found where the searches from their two ends meet, or 'Nothing' if they
-- do not.
--
-- The ranges are expected to differ at both ends.
middle ::
UArray Int Int ->
UArray Int Int ->
(Int, Int) ->
(Int, Int) ->
Maybe (Int, Int)
middle as bs (aLo, aHi) (bLo, bHi) = runST $ do
forth <- newArray (0, size - 1) (-1) :: ST s (STUArray s Int Int)
back <- newArray (0, size - 1) (-1) :: ST s (STUArray s Int Int)
writeArray forth (offset + 1) 0
writeArray back (offset + 1) 0
let rounds d fs fe bStart bEnd
| d >= maxD = pure Nothing
| otherwise =
forwards d (fs - d) fs fe >>= \case
Left found -> pure (Just found)
Right (fs', fe') ->
backwards d (bStart - d) bStart bEnd >>= \case
Left found -> pure (Just found)
Right (bStart', bEnd') -> rounds (d + 1) fs' fe' bStart' bEnd'
forwards d k fs fe
| k > d - fe = pure (Right (fs, fe))
| otherwise = do
x0 <- furthest forth d k
let x = slide (\i j -> as ! (aLo + i) == bs ! (bLo + j)) x0 (x0 - k)
y = x - k
writeArray forth (offset + k) x
if
| x > n -> forwards d (k + 2) fs (fe + 2)
| y > m -> forwards d (k + 2) (fs + 2) fe
| odd delta -> do
let o = offset + delta - k
other <-
if o >= 0 && o < size
then readArray back o
else pure (-1)
if other /= -1 && x >= n - other
then pure (Left (aLo + x, bLo + y))
else forwards d (k + 2) fs fe
| otherwise -> forwards d (k + 2) fs fe
backwards d k bStart bEnd
| k > d - bEnd = pure (Right (bStart, bEnd))
| otherwise = do
x0 <- furthest back d k
let x = slide (\i j -> as ! (aHi - 1 - i) == bs ! (bHi - 1 - j)) x0 (x0 - k)
y = x - k
writeArray back (offset + k) x
if
| x > n -> backwards d (k + 2) bStart (bEnd + 2)
| y > m -> backwards d (k + 2) (bStart + 2) bEnd
| even delta -> do
let o = offset + delta - k
other <- if o >= 0 && o < size then readArray forth o else pure (-1)
if other /= -1 && other >= n - x
then pure (Left (aLo + other, bLo + other - (delta - k)))
else backwards d (k + 2) bStart bEnd
| otherwise -> backwards d (k + 2) bStart bEnd
rounds 0 0 0 0 0
where
n = aHi - aLo
m = bHi - bLo
delta = n - m
maxD = (n + m + 1) `div` 2
offset = maxD
size = 2 * maxD + 2
slide same x y
| x < n, y < m, same x y = slide same (x + 1) (y + 1)
| otherwise = x
furthest v d k = do
below <- readArray v (offset + k - 1)
above <- readArray v (offset + k + 1)
pure $
if k == -d || (k /= d && below < above)
then above
else below + 1