packages feed

haskell-docs-cli-1.0.0.0: src/Docs/CLI/Haddock.hs

{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE GeneralisedNewtypeDeriving #-}
{-# LANGUAGE DerivingStrategies #-}

-- | Functions to parse and display Haddock HTML
module Docs.CLI.Haddock
  ( Html
  , HtmlPage
  , Declaration(..)
  , Module(..)
  , Package(..)
  , parseHtmlDocument
  , parseModuleDocs
  , parsePackageDocs
  , sourceLinks
  , fileInfo
  , HasCompletion(..)

  -- general html utils
  , innerString
  , prettyHtml
  , numbered
  , parseHoogleHtml
  , link
  )
  where

import Docs.CLI.Types

import Data.Bifunctor (first)
import Data.List.Extra (unescapeHTML)
import Data.Foldable (fold)
import Control.Monad (foldM)
import Data.Maybe (fromMaybe, mapMaybe, listToMaybe, fromJust)
import Data.List hiding (groupBy)
import Data.List.Extra (breakOn)
import Data.Maybe (isJust)
import Data.Char (isSpace)
import Data.Text (Text)
import Data.Set (Set)
import Data.ByteString.Lazy (ByteString)

import qualified Data.ByteString.Lazy as LB
import qualified Data.Text.Encoding as Text

import qualified Text.HTML.DOM as Html
import qualified Text.XML as Xml
import qualified Data.Text as Text
import qualified Data.List.NonEmpty as NonEmpty
import qualified Data.Map.Strict as Map
import qualified Data.Set as Set
import qualified Text.PrettyPrint.ANSI.Leijen as P

-- | An html element
newtype Html = Html Xml.Element
  deriving newtype (Show, Eq)

-- | The root of an html page
newtype HtmlPage = HtmlPage Xml.Element

-- | An exported declaration
data Declaration = Declaration
  { dAnchors    :: Set Anchor
  , dAnchor     :: Anchor -- ^ Main declaration anchor
  , dSignature  :: Html
  , dSignatureExpanded :: Html -- ^ Signature with argument documentation, if available.
  , dContent    :: [Html]
  , dModuleUrl  :: ModuleUrl
  , dDeclUrl    :: DeclUrl
  , dCompletion :: String
  -- ^ string to be used when selecting this declaration with tab completion
  }

data Module = Module
  { mTitle        :: String
  , mDescription  :: Maybe Html
  , mDeclarations :: [Declaration]
  , mUrl          :: ModuleUrl
  }

data Package = Package
  { pTitle       :: String
  , pSubTitle    :: Maybe String
  , pDescription :: Html
  , pReadme      :: Maybe Html
  , pProperties  :: [(String, Html)]
  , pModules     :: [String]
  , pUrl         :: PackageUrl
  }

-- | Types that can be selected with tab completion
class HasCompletion a where
  completion :: a -> String

instance HasCompletion a => HasCompletion (NonEmpty.NonEmpty a) where
  completion = completion . NonEmpty.head

instance HasCompletion String where
  completion = id

instance HasCompletion Declaration where
  completion = dCompletion

instance HasCompletion Module where
  completion = mTitle

instance HasCompletion Package where
  completion = pTitle

parseHtmlDocument :: ByteString -> HtmlPage
parseHtmlDocument = HtmlPage . Xml.documentRoot . Html.parseLBS

parseHoogleHtml :: String -> Html
parseHoogleHtml
  = Html
  . Xml.documentRoot
  . Html.parseLBS
  . LB.fromStrict
  . Text.encodeUtf8
  . Text.pack
  . (\v -> "<div>" <> v <> "</div>")

pageContent :: HasUrl url => String -> url -> [a] -> a
pageContent ty from parsed =
  case parsed of
    [x] -> x
    []  -> error $ "Unable to parse page as "<> what
    _   -> error $ "Ambiguous parse for "<> what
    where
      what = ty <> ": " <> getUrl from

parseModuleDocs :: ModuleUrl -> HtmlPage -> Module
parseModuleDocs murl (HtmlPage root) = pageContent "moduleDocs" murl $ do
  body    <- findM (is "body" . tag) $ children root
  content <- findM (is "content" . id_) $ children body
  let mtitle = do
        h <- findM (is "module-header" . id_) (children content)
        findM (is "caption" . class_) (children h)
      mdescription = findM (is "description" . id_) (children content)
  interface <- findM (is "interface" . id_) (children content)
  let title = Text.unpack $ maybe "" innerText mtitle
  return Module
    { mTitle = title
    , mDescription = Html <$> mdescription
    , mDeclarations = mapMaybe (parseDeclaration murl . Html) $ children interface
    , mUrl = murl
    }

noBullets :: Text
noBullets = "hcli-no-bullets"

parseDeclaration :: ModuleUrl -> Html -> Maybe Declaration
parseDeclaration moduleUrl (Html el) = do
  decl <- findM (is "top" . class_) [el]
  ([sigHead], elements) <- return
    $ partition (is "src" . class_) $ children decl
  (argsDocs, content) <- return
    $ first listToMaybe
    $ partition (is argumentsDocsClass . class_) elements

  -- we ignore declarations without anchors
  anchor <- listToMaybe (anchors sigHead)

  let
      args = case argsDocs of
        Just ds -> findDeep children (is "src" . class_) ds
        Nothing -> []

      signature = asTag "div"
        $ foldl' mergeNodes (removeTrailingSpaces $ fromJust $ removeInvisible sigHead)
        $ map (removeLeadingSpaces . addTrailingSpace) args

      signatureExpanded = fromMaybe signature $ listToMaybe $ do
        argsTable <- findM (is "table". tag) $ maybe [] children argsDocs
        return sigHead
          { Xml.elementNodes =
              Xml.elementNodes sigHead <>
                [ Xml.NodeElement lineBreak
                , Xml.NodeElement $ setClass noBullets argsTable
                ]
          }

  return Declaration
    { dAnchors = Set.fromList $ anchors el
    , dAnchor = anchor
    , dSignature = Html signature
    , dSignatureExpanded = Html signatureExpanded
    , dContent = Html <$> content
    , dModuleUrl = moduleUrl
    , dDeclUrl = DeclUrl moduleUrl anchor
    , dCompletion = Text.unpack $ innerText signature
    }
  where
    argumentsDocsClass = "subs arguments"

    lineBreak = Xml.Element (Xml.Name "br" Nothing Nothing) mempty []

    removeInvisible =
      filterDeep $ \node -> case node of
        Xml.NodeElement e
          | class_ e == "selflink" -> Nothing
        _ -> Just node

    asTag t e = e
      { Xml.elementName =
          (Xml.elementName e) { Xml.nameLocalName = t }
      }

    setClass name e = e
      { Xml.elementAttributes =
          Map.insert
            (Xml.Name "class" Nothing Nothing)
            name
            (Xml.elementAttributes e)
      }


    mergeNodes e1 e2 = e2
      { Xml.elementNodes = Xml.elementNodes e1 <> Xml.elementNodes e2
      }

    addTrailingSpace e = e
      { Xml.elementNodes = Xml.elementNodes e <> [Xml.NodeContent " " ]
      }

    removeTrailingSpaces e = res
      where Xml.NodeElement res = head $ snd $ rm False [Xml.NodeElement e]

    rm True xs = (True, xs)
    rm False [] = (False, [])
    rm False (x:xs) = case x of
      Xml.NodeInstruction _ -> rm False xs
      Xml.NodeContent txt -> (True, Xml.NodeContent (Text.dropWhileEnd isSpace txt) : xs)
      Xml.NodeComment _ -> rm False xs
      Xml.NodeElement e ->
        let (removed, nodes') = fmap reverse $ rm False $ reverse $ Xml.elementNodes e
            e' = Xml.NodeElement e { Xml.elementNodes = nodes' }
        in
        if removed
          then (True, e':xs)
          else (e':) <$> rm False xs

    removeLeadingSpaces e = res
      where Xml.NodeElement res = head $ snd $ rmLeading False [Xml.NodeElement e]

    rmLeading True xs = (True, xs)
    rmLeading False [] = (False, [])
    rmLeading False (x:xs) = case x of
      Xml.NodeInstruction _ -> rmLeading False xs
      Xml.NodeContent txt -> (True, Xml.NodeContent (Text.dropWhile isSpace txt) : xs)
      Xml.NodeComment _ -> rmLeading False xs
      Xml.NodeElement e ->
        let (removed, nodes') = rmLeading False $ Xml.elementNodes e
            e' = Xml.NodeElement e { Xml.elementNodes = nodes' }
        in
        if removed
          then (True, e':xs)
          else (e':) <$> rmLeading False xs

parsePackageDocs :: PackageUrl -> HtmlPage -> Package
parsePackageDocs url (HtmlPage root) = pageContent "packageDocs" url $ do
  body        <- findM (is "body" . tag) (children root)
  content     <- findM (is "content" . id_) (children body)
  heading     <- findM (is "h1" . tag) (children content)
  title       <- findM (is "a" . tag) (children heading)
  description <- findRec (is "description" . id_) content
  moduleList  <- findRec (is "modules" . id_) content
    >>= findM (is "module-list" . id_) . children
  let
    readme = findM (is "readme-container" . id_) (children content)
        >>= findM (is "embedded-author-content" . class_) . children

    subTitle = findM (is "small" . tag) (children heading)

    properties = do
      wrapper <- findM (is "properties" . id_) (children content)
             >>= findM (is "table" . tag) . children
             >>= findM (is "tbody" . tag) . children
      prop    <- filter (is "tr" . tag) (children wrapper)
      ptitle  <-
        filter (not . flip elem uninterestingProps)
        $ map (unescapeHTML . Text.unpack . innerText)
        $ findM (is "th" . tag) (children prop)
      pdesc <- findM (is "td" . tag) (children prop)
      return (ptitle, Html pdesc)

    modules = innerText <$> findRec (is "module" . class_) moduleList
  return Package
    { pTitle = Text.unpack $ innerText title
    , pSubTitle = Text.unpack . innerText <$> subTitle
    , pDescription = Html description
    , pReadme = Html <$> readme
    , pProperties = properties
    , pModules = Text.unpack <$> modules
    , pUrl = url
    }
  where
    -- Properties that are not interesting for the command line
    uninterestingProps = ["Your Rating", "Change log"]

-- | postorder traversal returning elements that match a predicate.
-- If the predicate is matched, the element's children are not explored
findRec :: (Xml.Element -> Bool) -> Xml.Element -> [Xml.Element]
findRec test root = go [root] []
  where
    go [] acc = acc
    go (el:siblings) acc
      | test el = el : go siblings acc
      | otherwise = go (children el) (go siblings acc)

-- | Find one. Fail otherwise.
findM :: (MonadFail m, Foldable t) => (a -> Bool) -> t a -> m a
findM f xs = do
  Just a <- return $ find f xs
  return a

is :: Eq a => a -> a -> Bool
is = (==)

children :: Xml.Element -> [Xml.Element]
children element =
  [ n | Xml.NodeElement n <- Xml.elementNodes element ]

tag :: Xml.Element -> Text
tag = Xml.nameLocalName . Xml.elementName

id_ :: Xml.Element -> Text
id_ = attr "id"

class_ :: Xml.Element -> Text
class_ = attr "class"

attr :: Text -> Xml.Element -> Text
attr name =
  fromMaybe ""
  . Map.lookup (Xml.Name name Nothing Nothing)
  . Xml.elementAttributes

innerString :: Html -> String
innerString (Html el) = Text.unpack (innerText el)

innerText :: Xml.Element -> Text
innerText el = flip foldMap (Xml.elementNodes el) $ \case
  Xml.NodeElement e -> innerText e
  Xml.NodeInstruction _ -> mempty
  -- TODO make this more performant
  Xml.NodeContent txt -> Text.pack $ unescapeHTML $ Text.unpack txt
  Xml.NodeComment _ -> mempty

anchors :: Xml.Element -> [Anchor]
anchors el = f $ foldMap anchors (children el)
  where
    f = if isAnchor el then (id_ el :) else id

    isAnchor e =
      class_ e == "def" &&
      (Text.isPrefixOf "t:" (id_ e) || Text.isPrefixOf "v:" (id_ e))

sourceLinks :: ModuleUrl -> HtmlPage -> [(Anchor, SourceLink)]
sourceLinks (ModuleUrl murl) (HtmlPage root) = do
  body        <- filter (is "body" . tag) $ children root
  content     <- filter (is "content" . id_) $ children body
  interface   <- filter (is "interface" . id_) $ children content
  declaration <- filter (is "top" . class_) $ children interface

  signature  <- findM (is "src" . class_) $ children declaration
  url <- map (toSourceUrl . attr "href")
    . findM (is "Source" . innerText)
    $ children signature
  srcAnchor <- takeAnchor url
  let surl = SourceLink (dropAnchor url) srcAnchor

  let constructors = filter (is "subs constructors" . class_) $ children declaration
  anchor <- foldMap anchors (signature : constructors)
  return (anchor, surl)
  where
    parent = reverse . tail . dropWhile (/= '/') . reverse

    toSourceUrl relativeUrl = parent murl <> "/" <> Text.unpack relativeUrl

-- ================================
-- Displaying Haddock's Html
-- ================================

class IsHtml a where
  toElement :: a -> Xml.Element

instance IsHtml Html where
  toElement (Html e) = e

instance IsHtml HtmlPage where
  toElement (HtmlPage p) = p

-- | Render Haddock's Html
prettyHtml :: IsHtml html => html -> P.Doc
prettyHtml = fromMaybe mempty . unXMLElement [] . toElement
  where
    unXMLElement stack e = style stack' e . fold =<< unXMLChildren stack' e
      where stack' = (tag e, class_ e):stack
    unXMLChildren stack e =
      case mapMaybe (unXMLNode stack) (Xml.elementNodes e) of
        [] -> Just [] -- TODO does this break stuff?
        xs -> Just xs
    unXMLNode stack = \case
      Xml.NodeInstruction _ -> Nothing
      Xml.NodeContent txt | Text.null txt -> Nothing
      Xml.NodeContent txt -> Just
        $ docwords id
        $ unescapeHTML
        $ Text.unpack txt
      Xml.NodeComment _ -> Nothing
      Xml.NodeElement e -> unXMLElement stack e

    docwords f [] = P.fillCat (f [])
    docwords f (x:xs)
      | isSpace x = docwords (f . (P.space :)) $ dropWhile isSpace xs
    docwords f xs = docwords (f . (P.text w :)) ys
      where (w, ys) = break isSpace xs

    -- | given an element, style its children
    style stack e m = classStyle stack e m  >>= tagStyle stack e

    classStyle stack e = case class_ e of
      ""                  -> Just
      -- layout
      "doc"               -> Just . P.nest 2
      "subs methods"      -> Just . P.nest 2
      "subs instances"    -> Just . P.nest 2
      "subs constructors" -> Just . P.nest 2
      -- a declaration wrapper
      "top"               -> const
                              $ Just . mappend P.hardline . P.vsep
                              $ mapMaybe (unXMLElement stack) (children e)
      -- style
      "caption"           | underClass "subs fields" -> hide
                          | otherwise ->  Just
      "name"              -> Just . P.dullgreen
      "def"               -> Just . P.dullgreen
      "fixity"            -> Just . P.black
      -- invisible
      "link"              -> hide
      "selflink"          -> hide
      -- modify
      "module-header"     -> const $ unXMLElement stack =<< findM (is "caption" . class_) (children e)
      _                   -> Just
      where
        underClass v = v `elem` map snd stack


    tagStyle stack e = case tag e of
       "h1"      -> Just . linebreak . mappend (P.text "# ")
       "h2"      -> Just . linebreak . mappend (P.text "## ")
       "h3"      -> Just . linebreak . mappend (P.text "### ")
       "h4"      -> Just . linebreak . mappend (P.text "#### ")
       "h5"      -> Just . linebreak . mappend (P.text "##### ")
       "h6"      -> Just . linebreak . mappend (P.text "###### ")
       "tt"      -> Just . P.green
       "pre"     -> const
                      $ Just . P.nest 2 . linebreak . P.vsep
                      $ map (P.black . P.text . Text.unpack)
                      $ Text.lines
                      $ innerText e
       "code"    -> Just . P.black
       "a"       -> Just . link
       "b"       -> Just
       "p"       -> Just . linebreak
       "br"      -> const $ Just P.hardline
       "dt"      -> Just . linebreak
       "dd"      -> Just . linebreak
       "summary" -> Just . linebreak
       "ol"      -> const
                    $ Just . linebreak . P.vsep . numbered
                    $ mapMaybe (unXMLElement stack) (children e)
       "ul"      -> const
                    $ Just . linebreak
                    $ (if underClass "subs fields"
                        then P.encloseSep
                              (P.fill 2 P.lbrace)
                              (P.hardline <> P.rbrace)
                              (P.fill 2 P.comma)
                        else P.vsep . map bullet
                      )
                    $ mapMaybe (unXMLElement stack) (children e)
       "td"      | isInstanceDetails e -> hide
                 | otherwise -> Just
       "table"   -> let
                        punctuate =
                          if underClass noBullets
                            then P.indent 2
                            else bullet
                    in
                    const
                    $ Just .  flip mappend P.hardline . P.vsep . map punctuate
                    $ mapMaybe (unXMLElement stack)
                    $ joinSubsections (children e)
       -- don't show instance details
       _         -> Just
      where
        underClass v = v `elem` map snd stack

    isInstanceDetails e = isSubsection e && isJust (findM (is "details" . tag) (children e))
    linebreak doc = P.hardline <> doc <> P.hardline
    hide = const Nothing
    isSubsection e = tag e == "td" && attr "colspan" e == "2"

    -- Haddock has a pattern of using a row with colspan=2 to store content
    -- that is a subsection of the previous row. Here we bundle these two rows
    -- together.
    joinSubsections [] = []
    joinSubsections [x] = [x]
    joinSubsections (a:b:xs)
      | Just _ <- findM (is "2" . attr "colspan") (children b) =
        joinSubsections (a { Xml.elementNodes = Xml.elementNodes a ++ Xml.elementNodes b } : xs)
      | otherwise = a:joinSubsections (b:xs)

-- | Convert an html page into a src file and inform of line
-- number of SourceLink
fileInfo :: SourceLink -> HtmlPage -> FileInfo
fileInfo s@(SourceLink url anchor) (HtmlPage root) = pageContent "fileInfo" s $ do
  body <- fmap removeAnnotations . filter (is "body" . tag) $ children root
  return $ FileInfo filename (anchorLine anchor body) (innerText body)
  where
    removeAnnotations :: Xml.Element -> Xml.Element
    removeAnnotations el = el
      { Xml.elementNodes = foldr go [] (Xml.elementNodes el)
      }
      where
        go :: Xml.Node -> [Xml.Node] -> [Xml.Node]
        go = \case
          Xml.NodeInstruction _ -> id
          Xml.NodeContent txt   -> (Xml.NodeContent txt :)
          Xml.NodeComment _     -> id
          Xml.NodeElement e
            | isAnnotation e -> id
            | otherwise      -> (Xml.NodeElement (removeAnnotations e) :)

        -- Annotations provide hover information in the browser.
        -- It is not useful in the command-line
        isAnnotation e = class_ e == "annottext"

    filename
      = (<> ".hs")
      $ map (\c -> if c == '/' then '-' else c)
      $ fst
      $ breakOn ".html"
      $ snd
      $ breakOn "src/" url

-- | File line where the tag is
anchorLine :: Anchor -> Xml.Element -> Maybe Int
anchorLine anchor
  = either Just (const Nothing)
  . anchorNodes 0
  . Xml.elementNodes
  where
    anchorNodes :: Int -> [Xml.Node] -> Either Int Int
    anchorNodes n = foldM anchorNode n

    anchorNode :: Int -> Xml.Node -> Either Int Int -- anchor line or total lines
    anchorNode n = \case
      Xml.NodeInstruction _ -> Right n
      Xml.NodeContent txt -> Right $ n + Text.count "\n" txt
      Xml.NodeComment _ -> Right n
      Xml.NodeElement e ->
        if attr "name" e == anchor || id_ e == anchor
          then Left n
          else anchorNodes n (Xml.elementNodes e)

-- | Traverse an acyclic graph depth-first and return list of nodes that
-- satisfy a predicate in postorder.
-- The children of notes that satisfy a predicate will not be checked.
findDeep :: forall a. (a -> [a]) -> (a -> Bool) -> a -> [a]
findDeep next test root = go root []
  where
    go :: a -> [a] -> [a]
    go x acc
      | test x = x : acc
      | otherwise = foldr go acc (next x)

filterDeep :: (Xml.Node -> Maybe Xml.Node) -> Xml.Element -> Maybe Xml.Element
filterDeep test el = unNodeElement <$> transform f test (Xml.NodeElement el)
  where
    unNodeElement (Xml.NodeElement e) = e
    unNodeElement _ = error "unNodeElement"

    f g node = case node of
      Xml.NodeElement e -> Xml.NodeElement e { Xml.elementNodes = g $ Xml.elementNodes e }
      _ -> node

-- We can impement filter with this, but not find.
transform :: forall a
  .  (([a] -> [a]) -> a -> a) -- ^ apply transformation to children
  -> (a -> Maybe a)           -- ^ transform one element
  -> a
  -> Maybe a
transform overChildren test = go
  where
    go :: a -> Maybe a
    go x = overChildren (mapMaybe go) <$> test x

-- =================================
-- Pretty priting
-- =================================

numbered :: [P.Doc] -> [P.Doc]
numbered = zipWith f [1..]
  where
    f n s = P.fill 2 (P.blue $ P.int n) P.<+> P.align s

bullet :: P.Doc -> P.Doc
bullet doc = P.fill 2 (P.char '-') <> P.align doc

link :: P.Doc -> P.Doc
link = P.dullcyan