packages feed

graft-0.1.0.0: test/EditTests.hs

{-# LANGUAGE OverloadedStrings #-}

module EditTests (editProperties) where

import Control.Exception (evaluate)
import Control.Monad.Trans.Class (lift)
import Control.Monad.Trans.Except (ExceptT, runExceptT, throwE)
import Control.Monad.Trans.State.Strict (State, modify', runState)
import qualified Data.ByteString as B
import Data.List (isPrefixOf, nub, sortOn, stripPrefix, unsnoc)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Text (Text)
import qualified Data.Text as Text
import qualified Data.Text.Encoding as TE
import Graft
import qualified Graft.Cursor as Cursor
import qualified Graft.Edit as Edit
import qualified Graft.Green as Green
import qualified Graft.Range as Range
import System.Mem.StableName (makeStableName)
import Test.QuickCheck
import TestModel

childOf :: Int -> GreenNode Kind -> GreenElement Kind
childOf index node = maybe (error "missing child") id (Green.childAt index node)

cursorAt :: Path -> GreenElement Kind -> Cursor Kind
cursorAt path base = either (error . show) id (Cursor.at path (Cursor.fromGreen base))

-- A second compilation of a model, kept apart from the first by the
-- optimiser, so that the two trees are equal but not the same object.
separately :: Model -> GreenElement Kind
separately = compile
{-# NOINLINE separately #-}

-- ---------------------------------------------------------------------------
-- Rewriting

-- What a rewrite is told at each path. The table is generated independently
-- of any traversal and read by the library's callback and by the model's, so
-- both see the same decisions.
data Decision = ReplaceWith Model | ReplaceSame | GoOn | GoOnWith Int | Untouched
  deriving (Show)

genTable :: Model -> Gen (Map Path Decision)
genTable model = Map.fromList <$> traverse decide [path | (path, _, _, _) <- contexts model]
  where
    decide path = (,) path <$> frequency
      [ (2, ReplaceWith <$> resize 3 arbitrary)
      , (1, pure ReplaceSame)
      , (5, pure GoOn)
      , (2, GoOnWith <$> chooseInt (0, 9))
      , (2, pure Untouched)
      ]

decisionAt :: Map Path Decision -> Path -> Decision
decisionAt table path = Map.findWithDefault GoOn path table

-- A replacement carries the context it was made with, so a context passed
-- to the wrong elements shows in the result.
stamped :: Int -> Model -> Model
stamped ctx new = Branch Group [Leaf Comment (show ctx), new]

-- The library's callback.
change :: Map Path Decision -> Int -> Cursor Kind -> Change Int Kind
change table ctx cursor = case decisionAt table (Cursor.path cursor) of
  ReplaceWith new -> Replace (compile (stamped ctx new))
  ReplaceSame -> Replace (Cursor.green cursor)
  GoOn -> Descend
  GoOnWith inner -> DescendWith inner
  Untouched -> Skip

-- The model's rewrite, over the oracle tree.
rewriteModel :: Map Path Decision -> Int -> Model -> Model
rewriteModel table = go []
  where
    go path ctx model = case decisionAt table path of
      ReplaceWith new -> stamped ctx new
      ReplaceSame -> model
      Untouched -> model
      GoOn -> below ctx
      GoOnWith inner -> below inner
      where
        below inner = case model of
          Leaf _ _ -> model
          Branch kind children ->
            Branch kind [go (path ++ [i]) inner child | (i, child) <- zip [0 ..] children]

-- The paths the callback is called on, in order: nothing below a
-- replacement or a skipped element.
visits :: Map Path Decision -> Model -> [Path]
visits table = go []
  where
    go path model = path : case decisionAt table path of
      GoOn -> below
      GoOnWith _ -> below
      _ -> []
      where
        below = case model of
          Leaf _ _ -> []
          Branch _ children -> concat [go (path ++ [i]) child | (i, child) <- zip [0 ..] children]

isReplacement, isContext, isSkip :: Decision -> Bool
isReplacement decision = case decision of { ReplaceWith _ -> True; ReplaceSame -> True; _ -> False }
isContext decision = case decision of { GoOnWith _ -> True; _ -> False }
isSkip decision = case decision of { Untouched -> True; _ -> False }

-- The rewrite gives the model's tree, kinds and empty elements included, with
-- valid caches in every rebuilt node.
propRewriteModel :: Model -> Property
propRewriteModel model = forAll (genTable model) $ \table ->
  let expected = rewriteModel table 0 model
      result = Edit.rewrite (change table) 0 (compile model)
      seen = map (decisionAt table) (visits table model)
  in checkCoverage $
     cover 20 (any isReplacement seen) "a replacement" $
     cover 10 (any isContext seen) "a context" $
     cover 10 (any isSkip seen) "a skip" $
     conjoin
       [ result === compile expected
       , Text.unpack (Green.render result) === source expected
       , counterexample "stale cached lengths or offsets" (validCaches result)
       ]

logged :: Map Path Decision -> Int -> Cursor Kind -> State [Path] (Change Int Kind)
logged table ctx cursor = do
  modify' (Cursor.path cursor :)
  pure (change table ctx cursor)

-- The callback runs in preorder, on exactly the elements the model says.
propRewriteVisits :: Model -> Property
propRewriteVisits model = forAll (genTable model) $ \table ->
  let (result, visited) = runState (Edit.rewriteM (logged table) 0 (compile model)) []
  in conjoin
       [ reverse visited === visits table model
       , result === compile (rewriteModel table 0 model)
       ]

-- A failing effect stops the rewrite at once: nothing after it is visited.
propRewriteStops :: Model -> Property
propRewriteStops model = forAll (genTable model) $ \table ->
  let planned = visits table model
  in forAll (chooseInt (0, length planned - 1)) $ \k ->
    let stopAt = planned !! k
        step :: Int -> Cursor Kind -> ExceptT Path (State [Path]) (Change Int Kind)
        step ctx cursor = do
          let path = Cursor.path cursor
          lift (modify' (path :))
          if path == stopAt then throwE path else pure (change table ctx cursor)
        (outcome, visited) = runState (runExceptT (Edit.rewriteM step 0 (compile model))) []
    in conjoin
         [ outcome === Left stopAt
         , reverse visited === take (k + 1) planned
         ]

-- A rewrite that replaces nothing returns its argument, not a copy.
propRewriteShares :: Model -> Property
propRewriteShares model = ioProperty $ do
  tree <- evaluate (compile model)
  original <- makeStableName tree
  skipped <- evaluate (Edit.rewrite (\() _ -> Skip) () tree) >>= makeStableName
  descended <- evaluate (Edit.rewrite (\() _ -> Descend) () tree) >>= makeStableName
  pure $ conjoin
    [ counterexample "Skip everywhere copied the tree" (skipped == original)
    , counterexample "Descend everywhere copied the tree" (descended == original)
    ]

-- A sibling with nothing replaced below it is reused, not copied.
propRewriteSharesSiblings :: Model -> Model -> Property
propRewriteSharesSiblings left right = ioProperty $ do
  let tree = compile (Branch Root [left, right])
      table = Map.fromList [([0], ReplaceWith (Leaf Name "new"))]
  rewritten <- evaluate (Edit.rewrite (change table) 0 tree)
  case (tree, rewritten) of
    (Node before, Node after) -> do
      untouched <- evaluate (childOf 1 before) >>= makeStableName
      kept <- evaluate (childOf 1 after) >>= makeStableName
      pure (counterexample "the untouched sibling was copied" (untouched == kept))
    _ -> pure (counterexample "the roots are not nodes" False)

-- The replacements a rewrite makes, applied as a plan to the original, give
-- the rewrite's result.
propRewriteIsApply :: Model -> Property
propRewriteIsApply model = forAll (genTable model) $ \table ->
  let base = compile model
      recording ctx cursor = do
        let answer = change table ctx cursor
        case answer of
          Replace new -> modify' (Edit.replace cursor new :)
          _ -> pure ()
        pure answer
      (rewritten, collected) = runState (Edit.rewriteM recording 0 base) []
  in Edit.apply base (reverse collected) === Right rewritten

rewriteRegressions :: Property
rewriteRegressions = once $ conjoin
  [ Green.render (Edit.rewrite (\() _ -> Replace (tok "y")) () (tok "x")) === "y"
  , Green.render (Edit.rewrite (\() _ -> Replace (tok "y")) () empty) === "y"
  , Edit.rewrite (\() _ -> Descend) () empty === empty
  , Edit.rewrite (\() _ -> DescendWith ()) () (tok "x") === tok "x"
  , Green.render (Edit.rewrite grow () wide) === "aéé béécéé"
  , counterexample "offsets after a longer child" (validCaches (Edit.rewrite grow () wide))
  ]
  where
    tok = Token . Green.token Name
    empty = Node (Green.node Root [])
    wide = Node (Green.node Root [tok "a", Token (Green.token Space " "), Node (Green.node Group [tok "b", tok "c"])])
    grow () cursor = case Cursor.green cursor of
      Token token | Green.tokenKind token == Name ->
        Replace (Token (Green.token Name (Green.text token <> "éé")))
      _ -> Descend

-- ---------------------------------------------------------------------------
-- Edit plans

-- What an edit does, at the path of the element it applies to; the model of
-- a child-anchored edit is a splice of its parent.
data ModelOp = MReplace Model | MSplice Int Int [Model]
  deriving (Show)

-- A library edit on the base with its model, or Nothing for a
-- child-anchored edit on the root, which has no model.
data Planned = Planned (Edit Kind) (Maybe (Path, ModelOp))
  deriving (Show)

plannedEdit :: Planned -> Edit Kind
plannedEdit (Planned edit _) = edit

-- The edits of a plan with their positions in the list.
type ModelPlan = [(Int, Path, ModelOp)]

modelPlan :: [Planned] -> Maybe ModelPlan
modelPlan planned =
  sequence [fmap (\(path, op) -> (n, path, op)) model | (n, Planned _ model) <- zip [0 ..] planned]

genPlan :: GreenElement Kind -> Model -> Gen [Planned]
genPlan base model = do
  count <- frequency [(1, pure 0), (6, chooseInt (1, 3)), (2, pure 4)]
  vectorOf count (genEdit base model)

genEdit :: GreenElement Kind -> Model -> Gen Planned
genEdit base model = frequency
  [ (4, anywhere $ \path _ -> do
      new <- resize 3 arbitrary
      pure (Planned (Edit.replace (cursorAt path base) (compile new)) (Just (path, MReplace new))))
  , (4, anywhere $ \path selected -> do
      let width = case selected of { Branch _ children -> length children; Leaf _ _ -> 0 }
      (start, end) <- genRange width
      new <- small
      pure (Planned (Edit.splice (cursorAt path base) start end (map compile new)) (Just (path, MSplice start end new))))
  , (2, child $ \path -> do
      new <- small
      pure (Planned (Edit.insertBefore (cursorAt path base) (map compile new)) (inParent path (\i -> MSplice i i new))))
  , (2, child $ \path -> do
      new <- small
      pure (Planned (Edit.insertAfter (cursorAt path base) (map compile new)) (inParent path (\i -> MSplice (i + 1) (i + 1) new))))
  , (2, child $ \path -> pure (Planned (Edit.remove (cursorAt path base)) (inParent path (\i -> MSplice i (i + 1) []))))
  ]
  where
    small = resize 2 (listOf (resize 2 arbitrary))
    anywhere make = do
      (path, _, selected, _) <- elements (contexts model)
      make path selected
    -- A child-anchored edit mostly targets a child; the root, which has no
    -- siblings, is tried now and then.
    child make = case [path | (path@(_ : _), _, _, _) <- contexts model] of
      [] -> make []
      paths -> frequency [(9, elements paths), (1, pure [])] >>= make
    inParent path op = case unsnoc path of
      Nothing -> Nothing
      Just (parent, index) -> Just (parent, op index)

-- Mostly valid ranges and insertions, with the occasional invalid range.
genRange :: Int -> Gen (Int, Int)
genRange width = frequency
  [ (5, do start <- chooseInt (0, width); end <- chooseInt (start, width); pure (start, end))
  , (4, do start <- chooseInt (0, width); pure (start, start))
  , (1, elements [(-1, 0), (0, -1), (1, 0), (width + 1, width + 1), (0, width + 1)])
  ]

modelAt :: Path -> Model -> Model
modelAt (index : rest) (Branch _ children) = modelAt rest (children !! index)
modelAt _ model = model

-- The splices that are not valid for the base, with the error the library
-- reports for them.
invalidSplices :: Model -> ModelPlan -> [(Int, ChildEditError)]
invalidSplices model plan =
  [ (n, err) | (n, path, MSplice start end _) <- plan, Just err <- [spliceError (modelAt path model) start end] ]
  where
    spliceError target start end = case target of
      Leaf _ _ -> Just CannotEditTokenChildren
      Branch _ children
        | start < 0 -> Just (NegativeChildBoundary start)
        | end < 0 -> Just (NegativeChildBoundary end)
        | end < start -> Just (ReversedChildRange start end)
        | start > count -> Just (ChildBoundaryOutOfBounds start)
        | end > count -> Just (ChildBoundaryOutOfBounds end)
        | otherwise -> Nothing
        where
          count = length children

-- The pairs of edits the three rules reject, stated on paths and ranges: a
-- replacement with any edit at or below it; a non-empty splice with an edit
-- at or below a child it consumes; two splices of one node whose ranges
-- [a, b) and [c, d) have a < d and c < b.
conflictPairs :: ModelPlan -> [(Int, Int)]
conflictPairs plan =
  [ (n, m)
  | (n, p, op) <- plan
  , (m, q, op') <- plan
  , n < m
  , conflicts (p, op) (q, op') || conflicts (q, op') (p, op)
  ]
  where
    conflicts (p, MReplace _) (q, _) = p `isPrefixOf` q
    conflicts (p, MSplice a b _) (q, MSplice c d _) | p == q = a < d && c < b
    conflicts (p, MSplice a b _) (q, _) = case stripPrefix p q of
      Just (i : _) -> a < b && a <= i && i < b
      _ -> False

-- Two insertions at one boundary of one node, whose order is the list's.
sameBoundary :: ModelPlan -> Bool
sameBoundary plan = length boundaries /= length (nub boundaries)
  where
    boundaries = [(p, i) | (_, p, MSplice i j _) <- plan, i == j]

-- The merge, written over lists: at each node, for each boundary, the
-- insertions there in list order, then the child or what replaces it.
mergeModel :: ModelPlan -> Model -> Model
mergeModel plan = go []
  where
    go path model = case [new | (_, p, MReplace new) <- plan, p == path] of
      new : _ -> new
      [] -> case model of
        Leaf _ _ -> model
        Branch kind children ->
          let splices = sortOn (\(n, _, _, _) -> n) [(n, a, b, xs) | (n, p, MSplice a b xs) <- plan, p == path]
              count = length children
              insertions i = concat [xs | (_, a, b, xs) <- splices, a == i, b == i]
              starting i = [xs | (_, a, b, xs) <- splices, a == i, b > i]
              consumed i = or [a <= i && i < b | (_, a, b, _) <- splices]
              slot i = case starting i of
                xs : _ -> xs
                []
                  | consumed i -> []
                  | otherwise -> [go (path ++ [i]) (children !! i)]
          in Branch kind (concat [insertions i ++ (if i < count then slot i else []) | i <- [0 .. count]])

-- A valid plan gives the model's merge; an invalid one is rejected for a
-- reason the model agrees with.
propApplyModel :: Model -> Property
propApplyModel model = forAll (genPlan base model) $ \planned ->
  let edits = map plannedEdit planned
      outcome = Edit.apply base edits
      atRoot = [n | (n, Planned _ Nothing) <- zip [0 :: Int ..] planned]
  in case modelPlan planned of
       Nothing -> label "anchored at the root" $ outcome === Left (EditAtRoot (minimum atRoot))
       Just plan ->
         let invalid = invalidSplices model plan
             conflicting = conflictPairs plan
             valid = null invalid && null conflicting
             expected = mergeModel plan model
         in checkCoverage $
            cover 30 valid "valid" $
            cover 10 (not (null conflicting)) "conflicting" $
            cover 3 (not (null invalid)) "an invalid splice" $
            if valid
              then case outcome of
                Right result -> conjoin
                  [ result === compile expected
                  , Text.unpack (Green.render result) === source expected
                  , counterexample "stale cached lengths or offsets" (validCaches result)
                  ]
                Left err -> counterexample ("rejected: " ++ show err) False
              else counterexample (show outcome) $ case outcome of
                Left (InvalidSplice n err) -> (n, err) `elem` invalid
                Left (ConflictingEdits n m) -> (n, m) `elem` conflicting
                _ -> False
  where
    base = compile model

-- A valid plan with no two insertions at one boundary gives the same tree
-- in any order.
propApplyOrder :: Model -> Property
propApplyOrder model = forAll (genPlan base model) $ \planned ->
  let edits = map plannedEdit planned
      applicable = case modelPlan planned of
        Just plan -> null (invalidSplices model plan) && null (conflictPairs plan) && not (sameBoundary plan)
        Nothing -> False
  in cover 30 applicable "order-independent plan" $
     if applicable
       then forAll (shuffle edits) $ \shuffled -> Edit.apply base shuffled === Edit.apply base edits
       else property True
  where
    base = compile model

-- Replace each edit's range in the original text, reading every range in
-- the original.
patch :: Text -> [TextEdit] -> Text
patch original edits = TE.decodeUtf8 (B.concat (go 0 edits))
  where
    bytes = TE.encodeUtf8 original
    go position [] = [B.drop position bytes]
    go position (TextEdit range new : rest) =
      B.take (start - position) (B.drop position bytes) : TE.encodeUtf8 new : go end rest
      where
        start = fromIntegral (Range.start range)
        end = fromIntegral (Range.end range)

-- Patching the base's text with the text edits renders the applied tree,
-- and the edits are sorted and apart, so the patch order doesn't matter.
propTextEdits :: Model -> Property
propTextEdits model = forAll (genPlan base model) $ \planned ->
  let edits = map plannedEdit planned
  in case (Edit.apply base edits, Edit.textEdits base edits) of
       (Right result, Right texts) -> conjoin
         [ patch (Green.render base) texts === Green.render result
         , counterexample ("not sorted and apart: " ++ show texts) (apart texts)
         ]
       (Left err, Left err') -> err === err'
       (outcome, texts) -> counterexample (show (outcome, texts)) False
  where
    base = compile model
    apart texts = and (zipWith (\a b -> Range.end (editRange a) < Range.start (editRange b)) texts (drop 1 texts))

-- An empty plan returns the base, and a sibling off every edit's path is
-- reused, not copied.
propApplyShares :: Model -> Model -> Property
propApplyShares left right = ioProperty $ do
  tree <- evaluate (compile (Branch Root [left, right]))
  same <- makeStableName tree
  unchanged <- evaluate (either (error . show) id (Edit.apply tree [])) >>= makeStableName
  let first = cursorAt [0] tree
  edited <- evaluate (either (error . show) id (Edit.apply tree [Edit.replace first (compile (Leaf Name "new"))]))
  case (tree, edited) of
    (Node before, Node after) -> do
      untouched <- evaluate (childOf 1 before) >>= makeStableName
      kept <- evaluate (childOf 1 after) >>= makeStableName
      pure $ conjoin
        [ counterexample "an empty plan copied the tree" (unchanged == same)
        , counterexample "the untouched sibling was copied" (untouched == kept)
        ]
    _ -> pure (counterexample "the roots are not nodes" False)

-- A cursor from an equal tree built separately is accepted, with the same
-- result; a cursor from a tree that differs anywhere, here above an equal
-- target, is rejected by its position in the list.
propCompatibility :: Model -> Property
propCompatibility model = forAll (genPlan twin model) $ \planned ->
  let edits = map plannedEdit planned
      wrapped = compile (Branch Group [model])
      fromOther = [Edit.replace (cursorAt [0] wrapped) (compile (Leaf Name "x"))]
      anchoredAtRoot = or [True | Planned _ Nothing <- planned]
  in conjoin
       [ counterexample "an equal tree built separately" (Edit.apply base edits === Edit.apply twin edits)
       , counterexample "a different tree" (Edit.apply base fromOther === Left (EditFromAnotherTree 0))
       , counterexample "a different tree among edits" $
           anchoredAtRoot || Edit.apply base (edits ++ fromOther) == Left (EditFromAnotherTree (length edits))
       ]
  where
    base = compile model
    twin = separately model

planRegressions :: Property
planRegressions = once $ conjoin
  [ -- insertions at the ends of a splice are placed by the merge; inside, they conflict
    rendered [Edit.splice root 0 2 [tok "X"], Edit.insertBefore a [tok "I"]] === Right "IXbc"
  , rendered [Edit.splice root 0 2 [tok "X"], Edit.insertAfter sp [tok "J"]] === Right "XJbc"
  , rendered [Edit.splice root 0 2 [tok "X"], Edit.insertAfter a [tok "K"]] === Left (ConflictingEdits 0 1)
    -- insertions at one boundary keep the order of the list
  , rendered [Edit.insertBefore sp [tok "P"], Edit.insertBefore sp [tok "Q"]] === Right "aPQ bc"
  , rendered [Edit.insertBefore sp [tok "Q"], Edit.insertBefore sp [tok "P"]] === Right "aQP bc"
    -- a removed child can't also be replaced; two replacements of one element conflict
  , rendered [Edit.remove a, Edit.replace a (tok "Z")] === Left (ConflictingEdits 0 1)
  , rendered [Edit.replace a (tok "Z"), Edit.replace a (tok "Z")] === Left (ConflictingEdits 0 1)
  , rendered [Edit.replace group (tok "Z"), Edit.remove b] === Left (ConflictingEdits 0 1)
    -- the root has no siblings; a token has no children; positions are checked
  , rendered [Edit.remove root] === Left (EditAtRoot 0)
  , rendered [Edit.replace b (tok "B"), Edit.insertAfter root []] === Left (EditAtRoot 1)
  , rendered [Edit.splice a 0 0 []] === Left (InvalidSplice 0 CannotEditTokenChildren)
  , rendered [Edit.splice root 3 5 []] === Left (InvalidSplice 0 (ChildBoundaryOutOfBounds 5))
  , rendered [Edit.splice root 3 3 [tok "E"]] === Right "a bcE"
    -- the whole tree can be replaced
  , rendered [Edit.replace root (tok "r")] === Right "r"
  , Edit.textEdits wide [Edit.replace root (tok "r")] === Right [TextEdit (Range.fromLength 0 4) "r"]
    -- adjacent edits are merged, and so are edits at one offset through an empty element
  , Edit.textEdits wide [Edit.replace a (tok "X"), Edit.replace sp (tok "Y")] === Right [TextEdit (Range.fromLength 0 2) "XY"]
  , (Green.render <$> Edit.apply narrow [Edit.insertAfter e [tok "X"], Edit.insertBefore e [tok "Y"]]) === Right "aYXb"
  , Edit.textEdits narrow [Edit.insertAfter e [tok "X"], Edit.insertBefore e [tok "Y"]] === Right [TextEdit (Range.fromLength 1 0) "YX"]
  , Edit.textEdits wide [] === Right []
  ]
  where
    tok = Token . Green.token Name
    wide = Node (Green.node Root [tok "a", Token (Green.token Space " "), Node (Green.node Group [tok "b", tok "c"])])
    root = Cursor.fromGreen wide
    a = cursorAt [0] wide
    sp = cursorAt [1] wide
    group = cursorAt [2] wide
    b = cursorAt [2, 0] wide
    narrow = Node (Green.node Root [tok "a", tok "", tok "b"])
    e = cursorAt [1] narrow
    rendered edits = Green.render <$> Edit.apply wide edits

editProperties :: [(String, Property)]
editProperties =
  [ ("rewrite gives the model's tree with valid caches", property propRewriteModel)
  , ("rewriteM visits the model's elements in preorder", property propRewriteVisits)
  , ("rewriteM stops at the first failure", property propRewriteStops)
  , ("a rewrite that replaces nothing returns its argument", property propRewriteShares)
  , ("a rewrite reuses untouched siblings", property propRewriteSharesSiblings)
  , ("a rewrite is the plan of its replacements", property propRewriteIsApply)
  , ("rewrite regressions: token and empty roots, longer children", rewriteRegressions)
  , ("apply gives the model's merge or the model's rejection", property propApplyModel)
  , ("apply is order-independent but for insertions at one boundary", property propApplyOrder)
  , ("text edits patch the source to the applied tree", property propTextEdits)
  , ("apply shares the base and untouched siblings", property propApplyShares)
  , ("edits from an equal tree are accepted, from another rejected", property propCompatibility)
  , ("plan regressions: boundaries, conflicts, errors and text edits", planRegressions)
  ]