packages feed

xrefcheck-0.3.0: src/Xrefcheck/Core.hs

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

{-# LANGUAGE PatternSynonyms #-}

-- | Various primitives.

module Xrefcheck.Core where

import Universum hiding ((^..))

import Control.Lens (folded, makeLenses, makePrisms, to, united, (^..))
import Data.Aeson (FromJSON (..), withText)
import Data.Char (isAlphaNum)
import Data.Char qualified as C
import Data.DList (DList)
import Data.DList qualified as DList
import Data.Map qualified as M
import Data.Reflection (Given)
import Data.Text qualified as T
import Fmt (Buildable (..), Builder)
import System.FilePath.Posix (isPathSeparator)
import Text.Interpolation.Nyan
import Time (Second, Time)

import Xrefcheck.Progress
import Xrefcheck.System
import Xrefcheck.Util

-----------------------------------------------------------
-- Types
-----------------------------------------------------------

-- | Markdown flavor.
--
-- Unfortunatelly, CMark renderers used on different sites slightly differ,
-- we have to account for that.
data Flavor
  = GitHub
  | GitLab
  deriving stock (Show, Enum, Bounded)

allFlavors :: [Flavor]
allFlavors = [minBound .. maxBound]

-- | Whether anchors are case-sensitive for a given Markdown flavour or not.
caseInsensitiveAnchors :: Flavor -> Bool
caseInsensitiveAnchors GitHub = True
caseInsensitiveAnchors GitLab = False

instance FromJSON Flavor where
  parseJSON = withText "flavor" $ \txt ->
    case T.toLower txt of
      "github" -> pure GitHub
      "gitlab" -> pure GitLab
      _ -> fail $ "Unknown flavor " <> show txt

-- | Description of element position in source file.
-- We keep this in text because scanners for different formats use different
-- representation of this thing, and it actually appears in reports only.
newtype Position = Position Text
  deriving stock (Show, Eq, Generic)

instance Buildable Position where
  build (Position pos) = build pos

-- | Full info about a reference.
data Reference = Reference
  { rName :: Text
    -- ^ Text displayed as reference.
  , rPos :: Position
    -- ^ Position in source file.
  , rInfo :: ReferenceInfo
    -- ^ More info about the reference.
  } deriving stock (Show, Generic)

-- | Info about the reference.
data ReferenceInfo
  = RIExternal ExternalLink
  | RIFile ReferenceInfoFile
  deriving stock (Show, Generic)

data ReferenceInfoFile = ReferenceInfoFile
  { rifAnchor :: Maybe Text
    -- ^ Section or custom anchor tag.
  , rifLink :: FileLink
    -- ^ More info about the link.
  } deriving stock (Show, Generic)

data ExternalLink
  = ELUrl Text
    -- ^ Reference to a file at outer site, e.g @[d](http://www.google.com/doodles)@.
  | ELOther Text
    -- ^ Entry not to be processed, e.g. @mailto:e-mail@.
  deriving stock (Show, Generic)

data FileLink
  = FLAbsolute RelPosixLink
    -- ^ Reference to a file or directory relative to the repository root.
  | FLRelative RelPosixLink
    -- ^ Reference to a file or directory relative to the given one.
  | FLLocal
    -- ^ Reference to this file.
  deriving stock (Show, Generic)

makePrisms ''ReferenceInfo
makePrisms ''ExternalLink

pattern PathSep :: Char
pattern PathSep <- (isPathSeparator -> True)

-- | Compute the 'ReferenceInfo' corresponding to a given link.
referenceInfo :: Text -> ReferenceInfo
referenceInfo url
  | hasUrlProtocol = RIExternal $ ELUrl url
  | hasProtocol = RIExternal $ ELOther url
  | null link = RIFile $ ReferenceInfoFile anchor FLLocal
  | otherwise = case T.uncons link of
      Just (PathSep, path) ->
        RIFile $ ReferenceInfoFile anchor $ FLAbsolute $ RelPosixLink path
      _ ->
        RIFile $ ReferenceInfoFile anchor $ FLRelative $ RelPosixLink link
  where
    hasUrlProtocol = "://" `T.isInfixOf` T.take 10 url
    hasProtocol = ":" `T.isInfixOf` T.take 10 url
    (link, anchor) = case T.splitOn "#" url of
      [t] -> (t, Nothing)
      t : ts -> (t, Just $ T.intercalate "#" ts)
      [] -> (url, Nothing)

-- | Context of anchor.
data AnchorType
  = HeaderAnchor Int
    -- ^ Every section header is usually an anchor
  | HandAnchor
    -- ^ They can be set up manually
  | BiblioAnchor
    -- ^ Id of entry in bibliography
  deriving stock (Show, Eq, Generic)

-- | A referable anchor.
data Anchor = Anchor
  { aType :: AnchorType
  , aName :: Text
  , aPos  :: Position
  } deriving stock (Show, Eq, Generic)

data FileInfoDiff = FileInfoDiff
  { _fidReferences :: DList Reference
  , _fidAnchors    :: DList Anchor
  }
makeLenses ''FileInfoDiff

diffToFileInfo :: FileInfoDiff -> FileInfo
diffToFileInfo (FileInfoDiff refs anchors) =
    FileInfo (DList.toList refs) (DList.toList anchors)

instance Semigroup FileInfoDiff where
  FileInfoDiff a b <> FileInfoDiff c d = FileInfoDiff (a <> c) (b <> d)

instance Monoid FileInfoDiff where
  mempty = FileInfoDiff mempty mempty

-- | All information regarding a single file we care about.
data FileInfo = FileInfo
  { _fiReferences :: [Reference]
  , _fiAnchors    :: [Anchor]
  } deriving stock (Show, Generic)
makeLenses ''FileInfo

data ScanPolicy
  = OnlyTracked
  -- ^ Scan and treat as existing only files tracked by Git.
  -- Warn when there are scannable files not added to Git yet.
  | IncludeUntracked
  -- ^ Also scan and treat as existing
  -- files that were neither tracked nor ignored by Git.
  deriving stock (Show, Eq)

data FileStatus
  = Scanned FileInfo
  | NotScannable
  -- ^ Files that are not supported by our scanners.
  | NotAddedToGit
  -- ^ We are not scanning files that are not added to git
  -- unless --include-untracked CLI option was enabled, but we're
  -- gathering information about them to improve reports.
  deriving stock (Show)

data DirectoryStatus
  = TrackedDirectory
  | UntrackedDirectory
  deriving stock (Show)

-- | All tracked files and directories.
data RepoInfo = RepoInfo
  { riFiles :: Map CanonicalRelPosixLink (RelPosixLink, FileStatus)
    -- ^ Files from the repo with `FileInfo` attached to files that we've scanned.
  , riDirectories :: Map CanonicalRelPosixLink (RelPosixLink, DirectoryStatus)
    -- ^ Directories containing those files.
  }

-- Search for a file in the repository.
lookupFile :: CanonicalRelPosixLink -> RepoInfo -> Maybe FileStatus
lookupFile path RepoInfo{..} =
  snd <$> M.lookup path riFiles

-- Search for a directory in the repository.
lookupDirectory :: CanonicalRelPosixLink -> RepoInfo -> Maybe DirectoryStatus
lookupDirectory path RepoInfo{..} =
  snd <$> M.lookup path riDirectories

-----------------------------------------------------------
-- Instances
-----------------------------------------------------------

instance NFData ReferenceInfo
instance NFData Anchor
instance NFData AnchorType
instance NFData ExternalLink
instance NFData FileInfo
instance NFData FileLink
instance NFData Position
instance NFData Reference
instance NFData ReferenceInfoFile

instance Given ColorMode => Buildable Reference where
  build Reference{..} =
    case rInfo of
      RIFile ReferenceInfoFile{..} ->
        case rifLink of
          FLLocal ->
            [int||
            reference #{paren $ colorIfNeeded Green "file-local"} at #{rPos}:
              - text: #s{rName}
              - anchor: #{rifAnchor ?: styleIfNeeded Faint "-"}
            |]
          FLRelative link ->
            [int||
            reference #{paren $ colorIfNeeded Yellow "relative"} at #{rPos}:
              - text: #s{rName}
              - link: #{link}
              - anchor: #{rifAnchor ?: styleIfNeeded Faint "-"}
            |]
          FLAbsolute link ->
            [int||
            reference #{paren $ colorIfNeeded Yellow "absolute"} at #{rPos}:
              - text: #s{rName}
              - link: /#{link}
              - anchor: #{rifAnchor ?: styleIfNeeded Faint "-"}
            |]
      RIExternal (ELUrl url) ->
        [int||
        reference #{paren $ colorIfNeeded Red "external"} at #{rPos}:
          - text: #s{rName}
          - link: #{url}
        |]
      RIExternal (ELOther url) ->
        [int||
        reference (other) at #{rPos}:
          - text: #s{rName}
          - link: #{url}
        |]

instance Given ColorMode => Buildable AnchorType where
  build = styleIfNeeded Faint . \case
    HeaderAnchor l -> colorIfNeeded Green ("header " <> headerLevelToRoman l)
    HandAnchor -> colorIfNeeded Yellow "handmade"
    BiblioAnchor -> colorIfNeeded Cyan "biblio"
    where
      headerLevelToRoman = \case
        1 -> "I"
        2 -> "II"
        3 -> "III"
        4 -> "IV"
        5 -> "V"
        6 -> "VI"
        n -> error "Bad header level: " <> show n

instance Given ColorMode => Buildable Anchor where
  build Anchor{..} =
    [int||
    #{aName} (#{aType}) at #{aPos}
    |]

instance Given ColorMode => Buildable FileInfo where
  build FileInfo{..} =
    [int||
    - references:
    #{ interpolateIndentF 4 $ maybe "none" interpolateBlockListF (nonEmpty _fiReferences) }
    - anchors:
    #{ interpolateIndentF 4 $ maybe "none" interpolateBlockListF (nonEmpty _fiAnchors) }
    |]

instance Given ColorMode => Buildable RepoInfo where
  build RepoInfo{..}
    | Just scanned <- nonEmpty [(name, info) | (_, (name, Scanned info)) <- toPairs riFiles]
    = interpolateUnlinesF $ buildFileReport <$> scanned
    where
      buildFileReport :: (RelPosixLink, FileInfo) -> Builder
      buildFileReport (name, info) =
        [int||
        #{ colorIfNeeded Cyan name }:
        #{ interpolateIndentF 2 $ build info }
        |]
  build _ = "No scannable files found."

-----------------------------------------------------------
-- Analysing
-----------------------------------------------------------

-- | Which parts of verification do we perform.
data VerifyMode
  = LocalOnlyMode
  | ExternalOnlyMode
  | FullMode

shouldCheckLocal :: VerifyMode -> Bool
shouldCheckLocal = \case
  LocalOnlyMode -> True
  ExternalOnlyMode -> False
  FullMode -> True

shouldCheckExternal :: VerifyMode -> Bool
shouldCheckExternal = \case
  LocalOnlyMode -> False
  ExternalOnlyMode -> True
  FullMode -> True

-- | Convert section header name to an anchor refering it.
-- Conversion rules: https://docs.gitlab.com/ee/user/markdown.html#header-ids-and-links
headerToAnchor :: Flavor -> Text -> Text
headerToAnchor flavor = \t -> t
  & T.toLower
  & mergeSpecialSymbols
  where
    joinSubsequentChars sym = toText . go . toString
      where
        go = \case
          (c1 : c2 : s)
            | c1 == c2 && c1 == sym -> go (c1 : s)
          (c : s) -> c : go s
          [] -> []

    mergeSpecialSymbols = case flavor of
      GitLab -> \t -> t
        & T.replace " " "-"
        & T.filter (\c -> isAlphaNum c || c == '_' || c == '-')
        & joinSubsequentChars '-'
      GitHub ->
        -- GitHub case is tricky, it can produce many hythens in a row, e.g.
        -- "A - B" -> "a---b"
        let tmp = '\0'; tmpT = T.singleton tmp
        in \t -> t
        & T.replace " " tmpT
        & joinSubsequentChars tmp
        & T.replace tmpT "-"
        & T.filter (\c -> isAlphaNum c || c == '_' || c == '-')

-- | When there are several anchors with the same name, github automatically attaches
-- "-<number>" suffixes to duplications to make them referable unambiguously.
-- For instance, if there are two headers called "description", they would gain
-- "description" and "description-1" anchors correspondingly.
--
-- This function strips this suffix and returns the original anchor in case when
-- suffix is present.
stripAnchorDupNo :: Text -> Maybe Text
stripAnchorDupNo t = do
  let strippedNo = T.dropWhileEnd C.isNumber t
  guard (length strippedNo < length t)
  T.stripSuffix "-" strippedNo

-----------------------------------------------------------
-- Visualisation
-----------------------------------------------------------

data VerifyProgress = VerifyProgress
  { vrLocal    :: !(Progress Int ())
  , vrExternal :: !(Progress Int Text)
  } deriving stock (Show)

initVerifyProgress :: [Reference] -> VerifyProgress
initVerifyProgress references = VerifyProgress
  { vrLocal = initProgressWitnessed $
      references ^.. folded . to rInfo . (_RIFile . united <> _RIExternal . _ELOther . united)
  , vrExternal = initProgressWitnessed . ordNub $
      references ^.. folded . to rInfo . _RIExternal . _ELUrl
  }

showAnalyseProgress :: Given ColorMode => VerifyMode -> Time Second -> VerifyProgress -> Text
showAnalyseProgress mode posixTime VerifyProgress{..} =
  mconcat . mconcat $
    [ [ "Verifying " ]
    , [ showProgress "local" 10 White posixTime vrLocal <> " "
      | shouldCheckLocal mode ]
    , [ showProgress "external" 15 Yellow posixTime vrExternal
      | shouldCheckExternal mode ]
    ]

reprintAnalyseProgress :: Given ColorMode =>
  Rewrite -> VerifyMode -> Time Second -> VerifyProgress -> IO ()
reprintAnalyseProgress rw mode posixTime p = putTextRewrite rw $
  showAnalyseProgress mode posixTime p