xrefcheck-0.3.1: src/Xrefcheck/Scan.hs
{- SPDX-FileCopyrightText: 2018-2019 Serokell <https://serokell.io>
-
- SPDX-License-Identifier: MPL-2.0
-}
{-# OPTIONS_GHC -Wno-orphans #-}
-- | Generalised repo scanner and analyser.
module Xrefcheck.Scan
( ExclusionConfig
, ExclusionConfig' (..)
, FileSupport
, ReadDirectoryMode(..)
, ScanAction
, ScanError (..)
, ScanErrorDescription (..)
, ScanResult (..)
, ScanStage (..)
, defaultCompOption
, defaultExecOption
, ecIgnoreL
, ecIgnoreLocalRefsToL
, ecIgnoreRefsFromL
, ecIgnoreExternalRefsToL
, firstFileSupport
, mkGatherScanError
, mkParseScanError
, reportScanErrs
, scanRepo
) where
import Universum hiding (_1, (%~))
import Control.Lens (_1, makeLensesWith, (%~))
import Data.Aeson (FromJSON (..), genericParseJSON, withText)
import Data.Map qualified as M
import Data.Reflection (Given)
import Fmt (Buildable (..), Builder, fmtLn)
import System.Directory (doesDirectoryExist, pathIsSymbolicLink)
import System.Process (cwd, proc, readCreateProcess)
import Text.Interpolation.Nyan
import Text.Regex.TDFA.Common (CompOption (..), ExecOption (..), Regex)
import Text.Regex.TDFA.Text qualified as R
import Xrefcheck.Core
import Xrefcheck.Progress
import Xrefcheck.System
import Xrefcheck.Util
-- | Type alias for ExclusionConfig' with all required fields.
type ExclusionConfig = ExclusionConfig' Identity
-- | Config of repositry exclusions.
data ExclusionConfig' f = ExclusionConfig
{ ecIgnore :: Field f [CanonicalRelGlobPattern]
-- ^ Files which we completely ignore.
, ecIgnoreLocalRefsTo :: Field f [CanonicalRelGlobPattern]
-- ^ Files references to which we do not verify.
, ecIgnoreRefsFrom :: Field f [CanonicalRelGlobPattern]
-- ^ Files, references in which we should not analyze.
, ecIgnoreExternalRefsTo :: Field f [Regex]
-- ^ Regular expressions that match external references we should not verify.
} deriving stock (Generic)
makeLensesWith postfixFields ''ExclusionConfig'
-- | File extension, dot included.
type Extension = String
-- | Whether the file is a symlink.
type IsSymlink = Bool
-- | Way to parse a file.
type ScanAction = FilePath -> RelPosixLink -> IO (FileInfo, [ScanError 'Parse])
-- | All supported ways to parse a file.
type FileSupport = IsSymlink -> Extension -> Maybe ScanAction
data ScanResult = ScanResult
{ srScanErrors :: [ScanError 'Gather]
, srRepoInfo :: RepoInfo
}
-- | A scan error indexed by different process stages.
--
-- Within 'Parse', 'seFile' has no information because the same
-- file is being parsed.
--
-- Within 'Gather', 'seFile' stores the 'FilePath' corresponding
-- to the file in where the error was found.
data ScanError (a :: ScanStage) = ScanError
{ seFile :: ScanStageFile a
, sePosition :: Position
, seDescription :: ScanErrorDescription
}
data ScanStage = Parse | Gather
type family ScanStageFile (a :: ScanStage) where
ScanStageFile 'Parse = ()
ScanStageFile 'Gather = RelPosixLink
deriving stock instance Show (ScanError 'Parse)
deriving stock instance Show (ScanError 'Gather)
deriving stock instance Eq (ScanError 'Parse)
deriving stock instance Eq (ScanError 'Gather)
-- | Make a 'ScanError' for the 'Parse' stage.
mkParseScanError :: Position -> ScanErrorDescription -> ScanError 'Parse
mkParseScanError = ScanError ()
-- | Promote a 'ScanError' from the 'Parse' stage
-- to the 'Gather' stage.
mkGatherScanError :: RelPosixLink -> ScanError 'Parse -> ScanError 'Gather
mkGatherScanError seFile ScanError{sePosition, seDescription} = ScanError
{ seFile
, sePosition
, seDescription
}
pprScanErr :: Given ColorMode => ScanError 'Gather -> Builder
pprScanErr ScanError{..} = hdr <> "\n" <> interpolateIndentF 2 msg <> "\n"
where
hdr, msg :: Builder
hdr =
styleIfNeeded Bold (build sePosition <> ": ") <>
colorIfNeeded Red "scan error:"
msg = build seDescription
reportScanErrs :: Given ColorMode => NonEmpty (ScanError 'Gather) -> IO ()
reportScanErrs errs = do
traverse_ (fmtLn . pprScanErr) errs
fmtLn $ colorIfNeeded Red $
"Scan errors dumped, " <> build (length errs) <> " in total."
data ScanErrorDescription
= LinkErr
| FileErr
| ParagraphErr Text
| UnrecognisedErr Text
deriving stock (Show, Eq)
instance Buildable ScanErrorDescription where
build = \case
LinkErr -> [int||Expected a LINK after "ignore link" annotation|]
FileErr -> [int||Annotation "ignore all" must be at the top of \
markdown or right after comments at the top|]
ParagraphErr txt -> [int||Expected a PARAGRAPH after \
"ignore paragraph" annotation, but found #{txt}|]
UnrecognisedErr txt -> [int||Unrecognised option "#{txt}"
Perhaps you meant <"ignore link"|"ignore paragraph"|"ignore all">|]
firstFileSupport :: [FileSupport] -> FileSupport
firstFileSupport fs isSymlink =
safeHead . catMaybes <$> traverse ($ isSymlink) fs
data ReadDirectoryMode
= RdmTracked
-- ^ Consider files tracked by Git, obtained from "git ls-files"
| RdmUntracked
-- ^ Consider files that are not tracked nor ignored by Git, obtained from
-- "git ls-files --others --exclude-standard"
| RdmBothTrackedAndUtracked
-- ^ Combine output from commands listed above, so we consider all files
-- except ones that are explicitly ignored by Git
-- | Process files that match given @ReadDirectoryMode@ and aren't ignored by the config.
readDirectoryWith
:: forall a. ReadDirectoryMode
-> ExclusionConfig
-> (RelPosixLink -> IO a)
-> FilePath
-> IO [(RelPosixLink, a)]
readDirectoryWith mode config scanner root = do
relativeFiles <- fmap mkRelPosixLink . fileLines <$> getFiles
traverse scanFile $ filter (not . isIgnored) relativeFiles
where
getFiles = case mode of
RdmTracked -> getTrackedFiles
RdmUntracked -> getUntrackedFiles
RdmBothTrackedAndUtracked -> liftA2 (<>) getTrackedFiles getUntrackedFiles
getTrackedFiles = readCreateProcess
(proc "git" ["ls-files", "-z"]){cwd = Just root} ""
getUntrackedFiles = readCreateProcess
(proc "git" ["ls-files", "-z", "--others", "--exclude-standard"]){cwd = Just root} ""
fileLines :: String -> [String]
fileLines (dropWhile (== '\0') -> ls) =
case break (== '\0') ls of
([], _) -> []
(f, ls') -> f : fileLines ls'
scanFile :: RelPosixLink -> IO (RelPosixLink, a)
scanFile c = (c,) <$> scanner c
isIgnored :: RelPosixLink -> Bool
isIgnored = matchesGlobPatterns (ecIgnore config) . canonicalizeRelPosixLink
scanRepo
:: MonadIO m
=> ScanPolicy
-> Rewrite
-> FileSupport
-> ExclusionConfig
-> FilePath
-> m ScanResult
scanRepo scanMode rw formatsSupport config root = do
putTextRewrite rw "Scanning repository..."
liftIO $ whenM (not <$> doesDirectoryExist root) $
die $ "Repository's root does not seem to be a directory: " <> root
(errs, processedFiles) <-
let mode = case scanMode of
OnlyTracked -> RdmTracked
IncludeUntracked -> RdmBothTrackedAndUtracked
in liftIO $ (gatherScanErrs &&& gatherFileStatuses)
<$> readDirectoryWith mode config processFile root
notProcessedFiles <- case scanMode of
OnlyTracked -> liftIO $
readDirectoryWith RdmUntracked config (const $ pure NotAddedToGit) root
IncludeUntracked -> pure []
scannableNotProcessedFiles <- liftIO $
filterM (fmap isJust . fileScanner . fst) notProcessedFiles
whenJust (nonEmpty $ map fst scannableNotProcessedFiles) $ \files -> hPutStrLn @Text stderr
[int|A|
Those files are not added by Git, so we're not scanning them:
#{interpolateBlockListF files}
Please run "git add" before running xrefcheck or enable \
--include-untracked CLI option to check these files.
|]
let trackedDirs = foldMap (getIntermediateDirs . fst) processedFiles
untrackedDirs = foldMap (getIntermediateDirs . fst) notProcessedFiles
return . ScanResult errs $ RepoInfo
{ riFiles = M.fromList $ fmap canonicalLinkEntry $ processedFiles <> notProcessedFiles
, riDirectories = M.fromList $ fmap canonicalLinkEntry (fmap (, TrackedDirectory) trackedDirs
<> fmap (, UntrackedDirectory) untrackedDirs)
}
where
fileScanner :: RelPosixLink -> IO (Maybe ScanAction)
fileScanner file = do
isSymlink <- pathIsSymbolicLink (filePathFromRoot root file)
pure $ formatsSupport isSymlink $ takeExtension file
gatherScanErrs
:: [(RelPosixLink, (FileStatus, [ScanError 'Parse]))]
-> [ScanError 'Gather]
gatherScanErrs = foldMap $ \(file, (_, errs)) ->
mkGatherScanError file <$> errs
gatherFileStatuses
:: [(RelPosixLink, (FileStatus, [ScanError 'Parse]))]
-> [(RelPosixLink, FileStatus)]
gatherFileStatuses = map (second fst)
processFile :: RelPosixLink -> IO (FileStatus, [ScanError 'Parse])
processFile file = do
mScanner <- fileScanner file
case mScanner of
Nothing -> pure (NotScannable, [])
Just scanner -> scanner root file <&> _1 %~ Scanned
canonicalLinkEntry
:: (RelPosixLink, a)
-> (CanonicalRelPosixLink, (RelPosixLink, a))
canonicalLinkEntry (a, b) = (canonicalizeRelPosixLink a, (a, b))
-----------------------------------------------------------
-- Yaml instances
-----------------------------------------------------------
instance FromJSON Regex where
parseJSON = withText "regex" $ \val -> do
let errOrRegex = R.compile defaultCompOption defaultExecOption val
either (error . show) return errOrRegex
-- Default boolean values according to
-- https://hackage.haskell.org/package/regex-tdfa-1.3.1.0/docs/Text-Regex-TDFA.html#t:CompOption
defaultCompOption :: CompOption
defaultCompOption = CompOption
{ caseSensitive = True
, multiline = True
, rightAssoc = True
, newSyntax = True
, lastStarGreedy = False
}
-- ExecOption value to improve speed
defaultExecOption :: ExecOption
defaultExecOption = ExecOption {captureGroups = False}
instance FromJSON (ExclusionConfig' Maybe) where
parseJSON = genericParseJSON aesonConfigOption
instance FromJSON (ExclusionConfig) where
parseJSON = genericParseJSON aesonConfigOption