xrefcheck-0.2.2: src/Xrefcheck/Scan.hs
{- SPDX-FileCopyrightText: 2018-2019 Serokell <https://serokell.io>
-
- SPDX-License-Identifier: MPL-2.0
-}
-- | Generalised repo scanner and analyser.
module Xrefcheck.Scan
( TraversalConfig
, TraversalConfig' (..)
, Extension
, ScanAction
, FormatsSupport
, RepoInfo (..)
, ScanError (..)
, ScanErrorDescription (..)
, ScanResult (..)
, normaliseTraversalConfigFilePaths
, scanRepo
, specificFormatsSupport
) where
import Universum
import Data.Aeson (FromJSON (..), genericParseJSON)
import Data.List qualified as L
import Data.Map qualified as M
import Data.Reflection (Given)
import Fmt (Buildable (..), nameF, (+|), (|+))
import System.Directory (doesDirectoryExist)
import System.FilePath
(dropTrailingPathSeparator, equalFilePath, splitDirectories, takeDirectory, takeExtension, (</>))
import System.Process (cwd, readCreateProcess, shell)
import Xrefcheck.Core
import Xrefcheck.Progress
import Xrefcheck.System (RelGlobPattern, matchesGlobPatterns, normaliseGlobPattern, readingSystem)
import Xrefcheck.Util
-- | Type alias for TraversalConfig' with all required fields.
type TraversalConfig = TraversalConfig' Identity
-- | Config of repositry traversal.
data TraversalConfig' f = TraversalConfig
{ tcIgnored :: Field f [RelGlobPattern]
-- ^ Files and folders, files in which we completely ignore.
} deriving stock (Generic)
instance FromJSON (TraversalConfig' Maybe) where
parseJSON = genericParseJSON aesonConfigOption
instance FromJSON (TraversalConfig) where
parseJSON = genericParseJSON aesonConfigOption
normaliseTraversalConfigFilePaths :: TraversalConfig -> TraversalConfig
normaliseTraversalConfigFilePaths = TraversalConfig . map normaliseGlobPattern . tcIgnored
-- | File extension, dot included.
type Extension = String
-- | Way to parse a file.
type ScanAction = FilePath -> IO (FileInfo, [ScanError])
-- | All supported ways to parse a file.
type FormatsSupport = Extension -> Maybe ScanAction
data ScanResult = ScanResult
{ srScanErrors :: [ScanError]
, srRepoInfo :: RepoInfo
} deriving stock (Show)
data ScanError = ScanError
{ sePosition :: Position
, seFile :: FilePath
, seDescription :: ScanErrorDescription
} deriving stock (Show, Eq)
instance Given ColorMode => Buildable ScanError where
build ScanError{..} =
"In file " +| styleIfNeeded Faint (styleIfNeeded Bold seFile) |+ "\n"
+| nameF ("scan error " +| sePosition |+ "") mempty |+ "\n⛀ "
+| seDescription |+ "\n\n\n"
data ScanErrorDescription
= LinkErr
| FileErr
| ParagraphErr Text
| UnrecognisedErr Text
deriving stock (Show, Eq)
instance Buildable ScanErrorDescription where
build = \case
LinkErr -> "Expected a LINK after \"ignore link\" annotation"
FileErr -> "Annotation \"ignore file\" must be at the top of \
\markdown or right after comments at the top"
ParagraphErr txt -> "Expected a PARAGRAPH after \
\\"ignore paragraph\" annotation, but found " +| txt |+ ""
UnrecognisedErr txt -> "Unrecognised option \"" +| txt |+ "\" perhaps you meant \
\<\"ignore link\"|\"ignore paragraph\"|\"ignore file\"> "
specificFormatsSupport :: [([Extension], ScanAction)] -> FormatsSupport
specificFormatsSupport formats = \ext -> M.lookup ext formatsMap
where
formatsMap = M.fromList
[ (extension, parser)
| (extensions, parser) <- formats
, extension <- extensions
]
-- | Process files that are tracked by git and not ignored by the config.
readDirectoryWith
:: forall a. TraversalConfig
-> (FilePath -> IO a)
-> FilePath
-> IO [(FilePath, a)]
readDirectoryWith config scanner root =
traverse scanFile
. filter (not . isIgnored)
. fmap (location </>)
. L.lines =<< readCreateProcess (shell "git ls-files"){cwd = Just root} ""
where
scanFile :: FilePath -> IO (FilePath, a)
scanFile = sequence . (normaliseWithNoTrailing &&& scanner)
isIgnored :: FilePath -> Bool
isIgnored = matchesGlobPatterns root $ tcIgnored config
-- Strip leading "." and trailing "/"
location :: FilePath
location =
if root `equalFilePath` "."
then ""
else dropTrailingPathSeparator root
scanRepo
:: MonadIO m
=> Rewrite -> FormatsSupport -> TraversalConfig -> FilePath -> m ScanResult
scanRepo rw formatsSupport config root = do
putTextRewrite rw "Scanning repository..."
when (not $ isDirectory root) $
die $ "Repository's root does not seem to be a directory: " <> root
(errs, fileInfos) <- liftIO
$ (gatherScanErrs &&& gatherFileInfos)
<$> readDirectoryWith config processFile root
let dirs = fromList $ foldMap (getDirs . fst) fileInfos
return . ScanResult errs $ RepoInfo (M.fromList fileInfos) dirs
where
isDirectory :: FilePath -> Bool
isDirectory = readingSystem . doesDirectoryExist
-- Get all directories from filepath.
getDirs :: FilePath -> [FilePath]
getDirs = scanl (</>) "" . splitDirectories . takeDirectory
gatherScanErrs
:: [(FilePath, Maybe (FileInfo, [ScanError]))]
-> [ScanError]
gatherScanErrs = fold . mapMaybe (fmap snd . snd)
gatherFileInfos
:: [(FilePath, Maybe (FileInfo, [ScanError]))]
-> [(FilePath, Maybe FileInfo)]
gatherFileInfos = map (second (fmap fst))
processFile :: FilePath -> IO $ Maybe (FileInfo, [ScanError])
processFile file = do
let ext = takeExtension file
let mscanner = formatsSupport ext
forM mscanner ($ file)