packages feed

xrefcheck-0.2.1: src/Xrefcheck/Scanners/Markdown.hs

{- SPDX-FileCopyrightText: 2018-2019 Serokell <https://serokell.io>
 -
 - SPDX-License-Identifier: MPL-2.0
 -}

{-# OPTIONS_GHC -fno-warn-orphans #-}

-- | Markdown documents markdownScanner.

module Xrefcheck.Scanners.Markdown
  ( MarkdownConfig' (..)
  , MarkdownConfig
  , IgnoreMode (..)
  , defGithubMdConfig
  , markdownScanner
  , markdownSupport
  , parseFileInfo
  , makeError
  ) where

import Universum

import CMarkGFM (Node (..), NodeType (..), PosInfo (..), commonmarkToNode)
import Control.Monad.Trans.Writer.CPS (Writer, runWriter, tell)
import Data.Aeson (FromJSON (..), genericParseJSON)
import Data.ByteString.Lazy qualified as BSL
import Data.DList qualified as DList
import Data.Default (def)
import Data.Text qualified as T
import Data.Text.Lazy qualified as LT
import Fmt (Buildable (..), blockListF, nameF, (+|), (|+))
import Text.HTML.TagSoup

import Xrefcheck.Core
import Xrefcheck.Scan
import Xrefcheck.Util

-- | Type alias for MarkdownConfig' with all required fields.
type MarkdownConfig = MarkdownConfig' Identity

data MarkdownConfig' f = MarkdownConfig
  { mcFlavor :: Field f Flavor
  } deriving stock (Generic)

instance FromJSON (MarkdownConfig' Maybe) where
  parseJSON = genericParseJSON aesonConfigOption

instance FromJSON (MarkdownConfig) where
  parseJSON = genericParseJSON aesonConfigOption

defGithubMdConfig :: MarkdownConfig
defGithubMdConfig = MarkdownConfig
  { mcFlavor = GitHub
  }

instance Buildable Node where
  build (Node _mpos ty subs) = nameF (show ty) $ blockListF subs

toPosition :: Maybe PosInfo -> Position
toPosition = Position . \case
  Nothing -> Nothing
  Just PosInfo{..}
    | startLine == endLine -> Just $
        startLine |+ ":" +| startColumn |+ "-" +| endColumn |+ ""
    | otherwise -> Just $
        "" +|
        startLine |+ ":" +| startColumn |+ " - " +|
        endLine |+ ":" +| endColumn |+ ""

-- | Extract text from the topmost node.
nodeExtractText :: Node -> Text
nodeExtractText = T.strip . mconcat . map extractText . nodeFlatten
  where
    extractText = \case
      TEXT t -> t
      CODE t -> t
      _ -> ""

    nodeFlatten :: Node -> [NodeType]
    nodeFlatten (Node _pos ty subs) = ty : concatMap nodeFlatten subs

data IgnoreMode
  = Link
  | Paragraph
  | File
  | None
  deriving stock (Eq)

-- | Bind `IgnoreMode` to its `PosInfo` so that we can tell where the
-- corresponding annotation was declared.
data Ignore = Ignore IgnoreMode (Maybe PosInfo)

type ScannerM a = StateT Ignore (Writer [ScanError]) a

-- | Empty `Ignore` state
ignoreNone :: Ignore
ignoreNone = Ignore None Nothing

-- | A fold over a `Node`.
cataNode :: (Maybe PosInfo -> NodeType -> [c] -> c) -> Node -> c
cataNode f (Node pos ty subs) = f pos ty (cataNode f <$> subs)

-- | Remove nodes with accordance with global `MarkdownConfig` and local
--   overrides.
removeIgnored :: FilePath -> Node -> Writer [ScanError] Node
removeIgnored fp = withIgnoreMode . cataNode remove
  where
    remove
      :: Maybe PosInfo
      -> NodeType
      -> [ScannerM Node]
      -> ScannerM Node
    remove pos ty subs = do
      Ignore mode modePos <- get
      let node = Node pos ty []
      case (mode, ty) of
        -- We expect to find a paragraph immediately after the
        -- `ignore paragraph` annotanion. If the paragraph is not
        -- found we should report an error.
        (Paragraph, PARAGRAPH) -> put ignoreNone $> defNode
        (Paragraph, x)         -> do
          lift . tell . makeError modePos fp mode $ prettyType x
          put ignoreNone
          Node pos ty <$> sequence subs

        -- We don't expect to find an `ignore file` annotation here,
        -- since that annotation should be at the top of the file and
        -- the file should already be ignored when `checkIgnoreFile` is called.
        -- We should report an error if we find it anyway.
        (File, _)              -> do
          lift . tell $ makeError modePos fp mode ""
          put ignoreNone
          Node pos ty <$> sequence subs

        -- When we find an `ignore link` annotation, we skip nodes until
        -- we find a link and ignore it, or we find another ignore annotation,
        -- then we should report an error and set new `Ignore` state.
        (Link, LINK {})        -> put ignoreNone $> defNode
        (Link, _)            ->
          case getIgnoreMode node of
            Just mode'  -> do
              lift . tell $ makeError modePos fp mode ""
              handleMode node mode'
            Nothing     -> Node pos ty <$> sequence subs

        -- When no `Ignore` state is set check next node for annotation,
        -- if found then set it as new `IgnoreMode` otherwise skip node.
        (None, _)              ->
          case getIgnoreMode node of
            Just mode' -> handleMode node mode'
            Nothing    -> Node pos ty <$> sequence subs

    handleMode
      :: Node
      -> IgnoreMode
      -> ScannerM Node
    handleMode node = \case
      -- Report unknown `IgnoreMode`.
      None   -> do
        let unrecognised = fromMaybe ""
              $ safeHead . drop 1 . words =<< getXrefcheckContent node
        lift . tell $ makeError (getPosition node) fp None unrecognised
        put ignoreNone $> defNode
      -- Set new `Ignore` state.
      mode'  -> put (Ignore mode' $ getPosition node) $> defNode

    prettyType :: NodeType -> Text
    prettyType ty =
      let mType = safeHead $ words $ show ty
      in fromMaybe "" mType

    withIgnoreMode
      :: ScannerM Node
      -> Writer [ScanError] Node
    withIgnoreMode action = action `runStateT` ignoreNone >>= \case
      -- We expect `IgnoreMode` to be `None` when we reach EOF,
      -- otherwise that means there was an annotation that didn't match
      -- any node, so we have to report that.
      (node, Ignore None _) -> pure node
      (node, (Ignore mode pos))
        | mode == Paragraph -> do
            tell $ makeError pos fp mode "EOF"
            pure node
        -- Link and File scan errors do not require extra text info
        -- to make error description.
        | otherwise      -> do
            tell $ makeError pos fp mode ""
            pure node


-- | Custom `foldMap` for source tree.
foldNode :: (Monoid a, Monad m) => (Node -> m a) -> Node -> m a
foldNode action node@(Node _ _ subs) = do
  a <- action node
  b <- concatForM subs (foldNode action)
  return (a <> b)

type ExtractorM a = ReaderT MarkdownConfig (Writer [ScanError]) a

-- | Extract information from source tree.
nodeExtractInfo
  :: FilePath
  -> Node
  -> ExtractorM FileInfo
nodeExtractInfo fp input@(Node _ _ nSubs) = do
  if checkIgnoreFile nSubs
  then return def
  else diffToFileInfo <$> (foldNode extractor =<< lift (removeIgnored fp input))

  where
    extractor :: Node -> ExtractorM FileInfoDiff
    extractor node@(Node pos ty _) =
      case ty of
        HTML_BLOCK _ -> do
          return mempty

        HEADING lvl -> do
          flavor <- asks mcFlavor
          let aType = HeaderAnchor lvl
          let aName = headerToAnchor flavor $ nodeExtractText node
          let aPos  = toPosition pos
          return $ FileInfoDiff DList.empty $ DList.singleton $ Anchor {aType, aName, aPos}

        HTML_INLINE text -> do
          let
            mName = do
              tag <- safeHead $ parseTags text
              attributes <- case tag of
                TagOpen a attrs
                  | T.toLower a == "a" -> Just attrs
                _ -> Nothing
              (_, name) <- find (\(field, _) -> T.toLower field `elem` ["name", "id"]) attributes
              pure name

          case mName of
            Just aName -> do
              let aType = HandAnchor
                  aPos  = toPosition pos
              return $ FileInfoDiff
                mempty
                (pure $ Anchor {aType, aName, aPos})

            Nothing -> do
              return mempty

        LINK url _ -> do
          let rName = nodeExtractText node
              rPos = toPosition pos
              link = if null url then rName else url
          let (rLink, rAnchor) = case T.splitOn "#" link of
                  [t]    -> (t, Nothing)
                  t : ts -> (t, Just $ T.intercalate "#" ts)
                  []     -> error "impossible"
          return $ FileInfoDiff
            (DList.singleton $ Reference {rName, rPos, rLink, rAnchor})
            DList.empty

        _ -> return mempty

-- | Check if there is `ignore file` at the beginning of the file,
-- ignoring preceding comments if there are any.
checkIgnoreFile :: [Node] -> Bool
checkIgnoreFile nodes =
  let isSimpleComment :: Node -> Bool
      isSimpleComment node = isComment node && not (isIgnoreFile node)

      mIgnoreFile = safeHead $ dropWhile isSimpleComment nodes
  in maybe False isIgnoreFile mIgnoreFile
  where
    isComment :: Node -> Bool
    isComment = isJust . getCommentContent

    isIgnoreFile :: Node -> Bool
    isIgnoreFile = (Just File ==) . getIgnoreMode

defNode :: Node
defNode = Node Nothing DOCUMENT [] -- hard-coded default Node

makeError
  :: Maybe PosInfo
  -> FilePath
  -> IgnoreMode
  -> Text
  -> [ScanError]
makeError pos fp mode txt = one . ScanError (toPosition pos) fp $ case mode of
  Link      -> linkMsg
  Paragraph -> paragraphMsg
  File      -> fileMsg
  None      -> unrecognisedMsg
  where
    fileMsg :: Text
    fileMsg =
      "Annotation \"ignore file\" must be at the top of \
      \markdown or right after comments at the top"

    linkMsg :: Text
    linkMsg = "Expected a LINK after \"ignore link\" annotation"

    paragraphMsg :: Text
    paragraphMsg = unwords
      [ "Expected a PARAGRAPH after \
          \\"ignore paragraph\" annotation, but found"
      , txt
      ]

    unrecognisedMsg :: Text
    unrecognisedMsg = unwords
      [ "Unrecognised option"
      , "\"" <> txt <> "\""
      , "perhaps you meant \
          \<\"ignore link\"|\"ignore paragraph\"|\"ignore file\"> "
      ]

getCommentContent :: Node -> Maybe Text
getCommentContent node = do
  txt <- getHTMLText node
  T.stripSuffix "-->" =<< T.stripPrefix "<!--" (T.strip txt)

getHTMLText :: Node -> Maybe Text
getHTMLText (Node _ (HTML_BLOCK txt) _) = Just txt
getHTMLText (Node _ (HTML_INLINE txt) _) = Just txt
getHTMLText _ = Nothing

getXrefcheckContent :: Node -> Maybe Text
getXrefcheckContent node =
  let notStripped = T.stripPrefix "xrefcheck:" . T.strip =<<
        getCommentContent node
  in T.strip <$> notStripped

-- | Get the correct position of an annotation node. There is a bug in
-- `commonmarkToNode` from the `cmark-gfm` package that affects one line
-- `HTML_BLOCK` nodes, those node have wrong end line and end column positions.
-- As our annotations are always oneliners, we can fix this by simply setting
-- end line equals to start line and calculating end column from start column
-- and annotation length.
getPosition :: Node -> Maybe PosInfo
getPosition node@(Node pos _ _) = do
  annLength <- length . T.strip <$> getHTMLText node
  PosInfo sl sc _ _ <- pos
  pure $ PosInfo sl sc sl (sc + annLength - 1)

-- | Extract `IgnoreMode` if current node is xrefcheck annotation.
getIgnoreMode :: Node -> Maybe IgnoreMode
getIgnoreMode node = textToMode . words =<< getXrefcheckContent node

textToMode :: [Text] -> Maybe IgnoreMode
textToMode ("ignore" : [x])
  | x == "link"      = return Link
  | x == "paragraph" = return Paragraph
  | x == "file"      = return File
  | otherwise        = return None
textToMode _         = Nothing

parseFileInfo :: MarkdownConfig -> FilePath -> LT.Text -> (FileInfo, [ScanError])
parseFileInfo config fp input
  = runWriter
  $ flip runReaderT config
  $ nodeExtractInfo fp
  $ commonmarkToNode [] []
  $ toStrict input

markdownScanner :: MarkdownConfig -> ScanAction
markdownScanner config path = parseFileInfo config path . decodeUtf8 <$> BSL.readFile path

markdownSupport :: MarkdownConfig -> ([Extension], ScanAction)
markdownSupport config = ([".md"], markdownScanner config)