tilia-0.0.1.0: src/Tilia/Run.hs
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
-- | Running the formatter over a set of files.
module Tilia.Run
( -- * Outcomes
Outcome (..),
declined,
failed,
differs,
exitCodeOf,
-- * Execution
runOver,
readAsUtf8,
formattingOutcome,
writeBack,
inParallel,
-- * Report
Report (..),
inplaceReport,
checkReport,
noted,
)
where
import Control.Concurrent (forkIO, getNumCapabilities, newEmptyMVar, putMVar, takeMVar)
import Control.Monad (replicateM)
import Data.ByteString qualified as BS
import Data.Foldable (for_, traverse_)
import Data.IORef
import Data.List (sortOn)
import Data.Map.Strict qualified as Map
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding qualified as T
import System.FilePath (takeExtension)
import Tilia.Diff (diffInFull)
import Tilia.Format
( FormatError (Unreadable),
Session,
describeFormatError,
formatErrorExitCode,
formatSource,
refused,
)
import Tilia.Newline (NewlineStyle (Lf), getNewlineStyle, setNewlineStyle)
import Tilia.Palette (Color (Bad, Good, Middling, Place), Palette, marker, paint)
import Tilia.Utils (attempted, indent, lineWidth, wrapTo)
----------------------------------------------------------------------------
-- Outcomes
-- | What became of one file.
data Outcome
= -- | Formatted, and it was already in that shape.
Unchanged
| -- | Formatted, and this is what it should say instead.
Changed Text Text
| -- | Declined.
Declined FormatError
| -- | Failed to format.
Failed FormatError
-- | Was the file left alone because we would not touch it?
declined :: Outcome -> Bool
declined = \case
Declined {} -> True
_ -> False
-- | Was the file left alone because something is wrong with it?
failed :: Outcome -> Bool
failed = \case
Failed {} -> True
_ -> False
-- | Would formatting change the file?
differs :: Outcome -> Bool
differs = \case
Changed {} -> True
_ -> False
-- | What a run that met a failure should exit with.
--
-- 'Nothing' where nothing failed. Where several did, the lowest of their
-- codes: they are all true, and one of them has to be picked, so it may as
-- well be picked the same way every time.
exitCodeOf :: [(FilePath, Outcome)] -> Maybe Int
exitCodeOf outcomes =
case [formatErrorExitCode e | (_, Failed e) <- outcomes] of
[] -> Nothing
codes -> Just (minimum codes)
----------------------------------------------------------------------------
-- Execution
-- | Format every file, as many at a time as the machine allows.
runOver :: Session -> [FilePath] -> IO [(FilePath, Outcome)]
runOver session = inParallel one
where
one path = do
!outcome <-
readAsUtf8 path >>= \case
Left why -> pure (Failed (Unreadable path why))
Right before ->
formatSource session path (setNewlineStyle Lf before) >>= \case
Left e -> pure (if refused e then Declined e else Failed e)
Right formatted -> pure (formattingOutcome before formatted)
pure (path, outcome)
-- | Read a source file as UTF-8.
readAsUtf8 :: FilePath -> IO (Either Text Text)
readAsUtf8 path =
attempted (BS.readFile path) >>= \case
Left why -> pure (Left why)
Right bytes -> pure $ case T.decodeUtf8' bytes of
Right text -> Right text
Left _ -> Left "it is not valid UTF-8"
-- | Formatting outcome for a file.
formattingOutcome ::
-- | The file, as it is
Text ->
-- | Its formatted text, in newlines
Text ->
Outcome
formattingOutcome before formatted
| after == before = Unchanged
| otherwise = Changed before after
where
after = setNewlineStyle (getNewlineStyle before) formatted
-- | Put a formatted file back, and only if it changed.
writeBack :: (FilePath, Outcome) -> IO ()
writeBack (path, outcome) = case outcome of
Changed _ after -> BS.writeFile path (T.encodeUtf8 after)
_ -> pure ()
-- | Run an action over every element at once, as far as the machine allows.
inParallel :: (a -> IO b) -> [a] -> IO [b]
inParallel act xs = do
capabilities <- getNumCapabilities
queue <- newIORef (zip [0 :: Int ..] xs)
answers <- newIORef Map.empty
let worker =
atomicModifyIORef' queue (\case [] -> ([], Nothing); (y : ys) -> (ys, Just y)) >>= \case
Nothing -> pure ()
Just (i, x) -> do
y <- act x
atomicModifyIORef' answers (\m -> (Map.insert i y m, ()))
worker
done <- replicateM (max 1 (min capabilities (length xs))) newEmptyMVar
for_ done $ \signal -> forkIO (worker >> putMVar signal ())
traverse_ takeMVar done
Map.elems <$> readIORef answers
----------------------------------------------------------------------------
-- Report
-- | What to print when a run is over, and on which stream.
data Report = Report
{ -- | For standard output.
reportOut :: [Text],
-- | For standard error.
reportErr :: [Text]
}
deriving (Eq, Show)
-- | The summary an @inplace@ run prints.
inplaceReport :: Palette -> [(FilePath, Outcome)] -> Report
inplaceReport palette outcomes =
Report
{ reportOut = tally palette ("✓", Good) "Formatted" (not . skipped) outcomes,
reportErr = asides palette outcomes
}
where
skipped o = declined o || failed o
-- | The diffs a @check@ run prints, and what it could not or would not do.
checkReport :: Palette -> [(FilePath, Outcome)] -> Report
checkReport palette outcomes =
Report
{ reportOut =
[ diffInFull palette path before after
| (path, Changed before after) <- outcomes
],
reportErr = asides palette outcomes
}
-- | Everything said about the files that were not formatted.
asides :: Palette -> [(FilePath, Outcome)] -> [Text]
asides palette outcomes =
concat
[ tally palette ("=", Middling) "Declined" declined outcomes,
reasons declined,
tally palette ("✗", Bad) "Failed" failed outcomes,
reasons failed
]
where
reasons wanted =
[ line
| (_, outcome) <- sortOn fst outcomes,
wanted outcome,
e <- why outcome,
line <- bulleted palette e
]
why = \case
Declined e -> [e]
Failed e -> [e]
_ -> []
-- | One line per extension, for the files a test picks out.
tally ::
Palette ->
-- | The mark to set the line under, and the color to set it in
(Text, Color) ->
-- | What became of the files being counted
Text ->
(Outcome -> Bool) ->
[(FilePath, Outcome)] ->
[Text]
tally palette (mark, color) what wanted outcomes =
[ indent 1 <> marker palette color mark <> " " <> what <> " " <> count palette n extension
| (extension, n) <- countedBy (wanted . snd) outcomes
]
-- | How many files of each extension, among the ones a test picks out.
countedBy :: ((FilePath, Outcome) -> Bool) -> [(FilePath, Outcome)] -> [(Text, Int)]
countedBy wanted =
Map.toList
. Map.fromListWith (+)
. map (\(path, _) -> (T.pack (takeExtension path), 1 :: Int))
. filter wanted
-- | Render the number of files.
count :: Palette -> Int -> Text -> Text
count palette n extension =
T.pack (show n)
<> " "
<> paint palette Place extension
<> (if n == 1 then " file" else " files")
-- | One case among several, opened by a bullet and wrapped underneath it.
bulleted :: Palette -> FormatError -> [Text]
bulleted palette e = case wrapTo (lineWidth - 6) (describeFormatError palette e) of
[] -> []
(opening : rest) ->
(indent 2 <> "· " <> opening) : map (indent 3 <>) rest
-- | Something to say under a mark of its own, wrapped to fit beneath it.
noted ::
Palette ->
-- | The mark to set it under, and the color to set that in
(Text, Color) ->
Text ->
[Text]
noted palette (mark, color) text = case wrapTo (lineWidth - 6) text of
[] -> []
(opening : rest) ->
(indent 1 <> marker palette color mark <> " " <> opening)
: map (indent 3 <>) rest