packages feed

mangrove-0.1.0.0: src/Web/Mangrove/Parse/Tree/Patch/Fold.hs

{-|
Description:    Functions to collapse a state-free instruction list into a document tree.

Copyright:      (c) 2020 Sam May
License:        MPL-2.0
Maintainer:     ag.eitilt@gmail.com

Stability:      provisional
Portability:    portable

This module provides the logic powering the second half of this
implementation's split
__[HTML](https://html.spec.whatwg.org/multipage/parsing.html#tree-construction)__
tree construction algorithm.  Namely, the functions in this module operate over
the single-dimensional stream of static tree-building instructions generated by
the rest of the "Web.Mangrove.Parse.Tree" hierarchy, folding them into a
simplified DOM tree.  For a more detailed discussion of the design behind this,
see the documentation of "Web.Mangrove.Parse.Tree.Patch".
-}
module Web.Mangrove.Parse.Tree.Patch.Fold
    ( buildTree
    ) where


import qualified Data.Bifunctor as F.B
import qualified Data.Either as E
import qualified Data.HashMap.Strict as M
import qualified Data.IntMap.Strict as M.I
import qualified Data.List as L
import qualified Data.Maybe as Y
import qualified Data.Text as T

import Web.Willow.DOM

import Web.Mangrove.Parse.Common.Error
import Web.Mangrove.Parse.Tree.Common hiding ( Token ( .. ) )
import Web.Mangrove.Parse.Tree.Patch


-- | Fold a series of instructions describing how to build a document tree
-- (without reference to any persistent state) into the tree they describe.
buildTree :: [Patch] -> Tree
buildTree = buildTree' . foldPatchset
  where buildTree' (ts, []) = emptyTree
            { node = Document mode'
            , children = ts'
            }
          where (ms, ts') = L.partition (\t -> nodeType (node t) == Just DocumentNode) ts
                mode' = foldr max NoQuirks $ Y.mapMaybe (getQuirksMode . node) ms
                getQuirksMode (Document mode) = Just mode
                getQuirksMode _ = Nothing
        buildTree' (_, RestartParsing : ps) = buildTree' $ foldPatchset ps
        buildTree' (ts, ps) = buildTree' . F.B.first (ts ++) $ foldPatchset ps

-- | Consume the next minimal sequence of folding instructions which would
-- result in at least one complete tree.  The input patchset is read according
-- to a sensible behaviour for trees providing a root for the document
-- (typically the /only/ root).
foldPatchset :: [Patch] -> ([Tree], [Patch])
foldPatchset ps = F.B.first (joinTexts . Y.mapMaybe filterNull) $ foldPatchset' False ps
  where -- Preprocess the placeholder tuples, where the 'InsertAt' carries the
        -- semantic data rather than the 'Tree'.
        filterNull (Right (_, t)) = Just $ Right t
        filterNull (Left c) = Just $ Left c

-- | Consume the next sequence of folding instructions which would result in a
-- complete tree.  Any intervening instructions which are destined for other
-- parts of the final tree are extracted for re-direction upward.  Any textual
-- data to be inserted directly into the tree is returned as a 'Left' value for
-- later packing into a single 'Text' node, to avoid the exponential complexity
-- of, e.g., @'Data.Text'.'T.cons'@.
foldPatchset'
    :: Bool
        -- ^ Whether this function call originates from somewhere higher up the
        -- document tree; any explicit calls will almost always use 'False'.
    -> [Patch]
    -> ([Either ([ParseError], Char) (InsertAt, Tree)], [Patch])
foldPatchset' _ [] = ([], [])
foldPatchset' isInner (ErrorList _:ps) = foldPatchset' isInner ps
-- Break infinite loop on unmatched node-closing patches.
foldPatchset' False (CloseNodes _:ps) = foldPatchset' False ps
foldPatchset' False (SoftCloseCurrentNode:ps) = foldPatchset' False ps
foldPatchset' False (DropCurrentNode:ps) = foldPatchset' False ps
foldPatchset' False (RestartParsing:ps) = foldPatchset' False ps
-- Stop processing on node-closing patches to enable recursive consumption of
-- all patches until the end of the node.
foldPatchset' True ps@(DropCurrentNode:_) = ([], ps)
foldPatchset' True ps@(RestartParsing:_) = ([], ps)
-- Push a "close ancestors" along until it reaches one of the node-closing
-- patches; at that point, drop the payload destined for the current location.
foldPatchset' True (p@(CloseNodes ls):ps) = case decrementReparenting ls of
    _ | M.I.null ls -> foldPatchset' True ps
    (0, toFloat) -> case ps of
        [] -> ([], [p])
        (CloseNodes ls':ps') ->
            foldPatchset' True $ CloseNodes (M.I.unionWith (+) ls ls') : ps'
        (DropCurrentNode:ps') ->
            ([], DropCurrentNode : CloseNodes toFloat : ps')
        (RestartParsing:_) ->
            ([], ps)
        (p'@InsertElement{}:ps') ->
            foldPatchset' True $ p' : CloseNodes (incrementReparenting ls) : ps'
        (p':ps') ->
            foldPatchset' True $ p' : p : ps'
    _ -> ([], p : ps)
-- Simple patch -> node translation.
foldPatchset' isInner (SetDocumentQuirks mode:ps) =
    F.B.first (Right (InDocument, packQuirks mode) :) $ foldPatchset' isInner ps
foldPatchset' isInner (InsertAndSetDocumentType _ d:ps) =
    F.B.first (Right (InDocument, packDoctype d) :) $ foldPatchset' isInner ps
foldPatchset' isInner (InsertComment _ loc txt:ps) =
    F.B.first (Right (loc, packComment txt) :) $ foldPatchset' isInner ps
foldPatchset' isInner (AddAttribute loc attr:ps) =
    F.B.first (Right (loc, packAttribute attr) :) $ foldPatchset' isInner ps
-- Prepends to the following text rather than the spec's appending to the
-- previous.  That should still have an identical result.
foldPatchset' isInner (InsertCharacter errs h:ps) =
    F.B.first (Left (errs, h) :) $ foldPatchset' isInner ps
-- Float non-element nodes up one level.
foldPatchset' True (SoftCloseCurrentNode:ps) =
    F.B.first (map floatTrees) $ foldPatchset' True ps
  where floatTrees t'@(Right (RelativeLocation l, t))
            | nodeType (node t) == Just ElementNode = t'
            | otherwise = Right (RelativeLocation $ succ l, t)
        -- Text nodes here don't take advantage of ahead-of-time gathering
        -- (thus need more complex concatenation), but shouldn't be a large
        -- portion of the document.
        floatTrees (Left (_, c)) = Right (RelativeLocation 1, emptyTree
            { node = Text $ T.singleton c
            })
        floatTrees t' = t'
-- Consume patches until and including the (matching) node-closing patch.
foldPatchset' isInner (InsertElement _ tag:ps) =
    let (ts, ps') = foldPatchset' True ps
        (ts', bubble) = filterFloaters ts $ floatsElement isInner tag
        (attrs, ts'') = L.partition isAttribute ts'
        tag' = tag
            { elementAttributes = M.union (elementAttributes tag) .
                fromAttrList $ Y.mapMaybe toAttribute attrs
            }
        this = Right (RelativeLocation 0, packElement tag' $ joinTexts ts'')
    in  case ps' of
        [] -> (this : bubble, [])
        (DropCurrentNode:ps'') -> F.B.first (++ bubble) $ foldPatchset' True ps''
        (CloseNodes ls:ps'') ->
            let (here, toFloat) = decrementReparenting ls
                ls' = case here of
                    l | l >= 2 -> M.I.insertWith (+) 0 (pred l) toFloat
                    _ -> toFloat
                (out, trail) = foldPatchset' True $ CloseNodes ls' : ps''
            in  (this : bubble ++ out, trail)
        (RestartParsing:_) -> ([], ps')
        _ ->
            let (out, trail) = foldPatchset' True ps'
                cleaned = map (fmap $ \c -> (RelativeLocation 0, c)) ts''
            in  (cleaned ++ out ++ bubble, trail)
  where isAttribute (Right tok) = nodeType (node tok) == Just AttributeNode
        isAttribute (Left _) = False
        toAttribute (Right tok) = case node tok of
            Attribute attr -> Just attr
            _ -> Nothing
        toAttribute (Left _) = Nothing


-- | Given a heterogeneous set of patches potentially destined for multiple
-- places within the document hierarchy, and a known set of addresses for the
-- current location, partition out the relevant patches from those continuing
-- onward.
filterFloaters
    :: [Either ([ParseError], Char) (InsertAt, Tree)]
    -> [InsertAt]
    -> ([Either ([ParseError], Char) Tree], [Either ([ParseError], Char) (InsertAt, Tree)])
filterFloaters ts here = F.B.bimap
    (map $ F.B.second snd)
    (map . fmap $ F.B.first decrementTarget) $
    L.partition isHere ts
  where decrementTarget loc = case loc of
            RelativeLocation 0 -> loc
            RelativeLocation i -> RelativeLocation $ pred i
            _ -> loc
        isHere (Right (l, _)) = elem l here
        isHere (Left _) = True

-- | Calculate the accepted addresses for the element at the current location;
-- usually just @'RelativeLocation' 0@, but a root @\<html\>@ node also accepts
-- 'InHtmlElement'.
floatsElement
    :: Bool
        -- ^ Whether this node is a descendant node of the root.
    -> ElementParams
    -> [InsertAt]
floatsElement isInner d
    | elementName d == T.pack "html" && not isInner = [RelativeLocation 0, InHtmlElement]
    | otherwise = [RelativeLocation 0]


-- | Given the list of reparenting directives used by a 'CloseNodes'
-- instruction, increase the distance to the node-to-reparent to account for
-- the patch being pushed into a child node.
incrementReparenting :: M.I.IntMap ReparentDepth -> M.I.IntMap ReparentDepth
incrementReparenting = M.I.mapKeys succ

-- | Given the list of reparenting directives used by a 'CloseNodes'
-- instruction, partition out the accumulated directives intended for the
-- current node, and decrease the distance to the node-to-reparent to account
-- for the child node being closed.
decrementReparenting :: M.I.IntMap ReparentDepth -> (ReparentDepth, M.I.IntMap ReparentDepth)
decrementReparenting ls = (Y.fromMaybe 0 $ M.I.lookup 0 ls, M.I.mapKeys pred $ M.I.delete 0 ls)


-- | Collapse all sequential 'Char' sequences in the list and pack them into a
-- single 'Text' node, preserving the interspersed complete subtrees.
joinTexts :: [Either ([ParseError], Char) Tree] -> [Tree]
joinTexts [] = []
joinTexts (Right t : xs) = t : joinTexts xs
joinTexts (Left c : xs) = txt' : joinTexts xs'
  where (cs', xs') = span E.isLeft xs
        cs = map (either id $ error "unexpected 'Right' after @span isLeft@") cs'
        (_, txt) = unzip $ c : cs
        txt' = emptyTree
            { node = Text $ T.pack txt
            }


-- | Wrap the desired level of backwards compatibility into a 'Document' node
-- as a placeholder.
packQuirks :: QuirksMode -> Tree
packQuirks mode = emptyTree
    { node = Document mode
    }

-- | Wrap a string of characters in the data types expected for a 'Comment'
-- node in the output of 'buildTree'.
packComment :: T.Text -> Tree
packComment txt = emptyTree
    { node = Comment txt
    }

-- | Wrap the metadata contained in a document type declaration in the data
-- types expected for a 'DocumentType' node in the output of 'buildTree'.
packDoctype :: DocumentTypeParams -> Tree
packDoctype dtd = emptyTree
    { node = DocumentType dtd
    }

-- | Wrap the metadata contained in a markup tag in the data types expected for
-- an 'Element' node in the output of 'buildTree'.  If it represents an HTML
-- @\<template\>@ element, additionally wrap the children in a
-- 'DocumentFragment' node to indicate the @[template
-- contents](https://html.spec.whatwg.org/scripting.html#template-contents)@
-- parameter.
packElement :: ElementParams -> [Tree] -> Tree
packElement tag childTrees
    | nodeIsElement (T.pack "template") tag = emptyTree
        { node = e
        , children = [emptyTree
            { node = DocumentFragment
            , children = childTrees
            }]
        }
    | otherwise = emptyTree
        { node = e
        , children = childTrees
        }
  where e = Element tag

-- | Wrap the metadata contained in a metadata tag's attribute in the data
-- types expected for an 'Attribute' node in the output of 'buildTree'; this
-- will then be subsumed into the parent 'Element' node, and does not remain in
-- the final document tree.
packAttribute :: AttributeParams -> Tree
packAttribute attr = emptyTree
    { node = Attribute attr
    }