packages feed

tilia-0.1.0.0: app/Main.hs

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}

module Main (main) where

import Control.Monad (when)
import Data.ByteString qualified as BS
import Data.Choice (Choice, fromBool, isTrue)
import Data.Foldable (traverse_)
import Data.Maybe (mapMaybe)
import Data.Text (Text)
import Data.Text.Encoding qualified as T
import Data.Text.IO qualified as T
import Data.Version (showVersion)
import GHC.IO.Encoding (TextEncoding (textEncodingName))
import Options.Applicative
import Paths_tilia (version)
import System.Directory (makeRelativeToCurrentDirectory)
import System.Exit (ExitCode (..))
import System.Exit qualified
import System.IO
  ( Handle,
    hFlush,
    hGetEncoding,
    hSetEncoding,
    mkTextEncoding,
    stderr,
    stdin,
    stdout,
  )
import Tilia.Cabal.Project (ProjectRoot, findProjectRoot)
import Tilia.Cabal.Target
  ( Component,
    Target,
    componentInPlan,
    componentsOfTarget,
    describeTargetProblem,
    filesOfComponents,
    parseTarget,
  )
import Tilia.Editor (editorSession, formatBuffer)
import Tilia.Fixity.Debug (renderFixityNotes)
import Tilia.Format
  ( FormatError (Unreadable),
    PlanSource (..),
    Session,
    describeFormatError,
    fixityNotesOf,
    formatErrorExitCode,
    newSession,
  )
import Tilia.Palette (Color (Bad), Palette, paletteFor)
import Tilia.Parser (ghcLibParserVersion)
import Tilia.Run
  ( Outcome (..),
    Report (..),
    checkReport,
    differs,
    exitCodeOf,
    failIfDeclined,
    inplaceReport,
    noted,
    runOver,
    stdinReport,
    writeBack,
  )
import Tilia.Utils (asUtf8, lineWidth, quietly)

-- | The program's entry point.
main :: IO ()
main = do
  traverse_ transliterateUnprintable [stdout, stderr]
  opts <- customExecParser (prefs (columns lineWidth)) optsParserInfo
  palette <- paletteFor
  let cmd = optCommand opts
  outcomes <- case cmd of
    Inplace target -> do
      outcomes <- formatComponents palette opts target
      traverse_ writeBack outcomes
      outcomes <$ printReport (inplaceReport palette outcomes)
    Check target -> do
      outcomes <- formatComponents palette opts target
      outcomes <$ printReport (checkReport palette outcomes)
    ForEditor file -> do
      (input, outcomes) <- formatStdin palette opts file
      outcomes <$ printReport (stdinReport palette input outcomes)
  exitWith cmd outcomes

-- | Format the files of the components a target asks for.
formatComponents ::
  Palette ->
  Opts ->
  Maybe String ->
  IO [(FilePath, Outcome)]
formatComponents palette opts@Opts{..} target = do
  (root, components) <-
    componentsFor palette
      =<< either
        (die usageExitCode palette)
        pure
        (maybe (parseTarget "all") parseTarget target)
  files <-
    traverse makeRelativeToCurrentDirectory
      =<< filesOfComponents root components
  session <-
    newSession
      "."
      ( case optBuildPlan of
          Nothing -> PlanFromCabal (mapMaybe componentInPlan components)
          Just givenPlan -> GivenPlan givenPlan
      )
      optUseCache
      optDownload
      optCheckAst
      optCheckIdempotence
      optDebugFixity
      >>= either (dieFormatting palette) pure
  formatWith palette opts session (`runOver` files)

-- | Format the module read from standard input as the given file, and say
-- what became of it, together with the module as it was read.
formatStdin ::
  Palette ->
  Opts ->
  FilePath ->
  IO (Text, [(FilePath, Outcome)])
formatStdin palette opts@Opts{..} file = do
  input <- readStandardInput
  case asUtf8 input of
    Left why -> pure ("", [(file, Failed (Unreadable file why))])
    Right before -> do
      outcomes <-
        editorSession
          file
          optBuildPlan
          optUseCache
          optDownload
          optCheckAst
          optCheckIdempotence
          optDebugFixity
          >>= \case
            Left e -> pure [(file, Failed e)]
            Right session ->
              formatWith palette opts session $ \s ->
                pure . (file,) <$> formatBuffer s file before
      pure (before, outcomes)

-- | Format with a session and print how it settled fixities, counting a
-- declined file as failed where the options say so.
formatWith ::
  Palette ->
  Opts ->
  Session ->
  (Session -> IO [(FilePath, Outcome)]) ->
  IO [(FilePath, Outcome)]
formatWith palette Opts{..} session formatting = do
  outcomes <- formatting session
  printFixityNotes palette session
  pure $
    if isTrue optMustNotDecline
      then fmap (fmap failIfDeclined) outcomes
      else outcomes

-- | Read standard input to its end without closing it, which would hand its
-- descriptor to whatever is opened next.
readStandardInput :: IO BS.ByteString
readStandardInput = BS.concat <$> chunks
  where
    chunks = do
      chunk <- BS.hGetSome stdin 32768
      if BS.null chunk then pure [] else (chunk :) <$> chunks

-- | Print how the session settled every file's fixities, if it was asked to
-- keep an account of that.
printFixityNotes :: Palette -> Session -> IO ()
printFixityNotes palette session =
  fixityNotesOf session
    >>= traverse_ (T.hPutStrLn stderr) . renderFixityNotes palette

-- | Transliterate unprintable characters if the output stream cannot handle
-- them.
transliterateUnprintable :: Handle -> IO ()
transliterateUnprintable h =
  quietly () $
    hGetEncoding h >>= \case
      Just encoding
        | name <- textEncodingName encoding,
          '/' `notElem` name ->
            hSetEncoding h =<< mkTextEncoding (name <> "//TRANSLIT")
      _ -> pure ()

-- | Exit with a status code determined by the 'Command' and the formatting
-- 'Outcome's.
exitWith :: Command -> [(FilePath, Outcome)] -> IO ()
exitWith cmd outcomes = case exitCodeOf outcomes of
  Just code -> System.Exit.exitWith (ExitFailure code)
  Nothing -> case cmd of
    Inplace _ -> pure ()
    Check _ ->
      when
        (any (differs . snd) outcomes)
        (System.Exit.exitWith (ExitFailure 1))
    ForEditor _ -> pure ()

-- | Print a 'Report'.
printReport :: Report -> IO ()
printReport report = do
  BS.putStr (T.encodeUtf8 (reportOut report))
  hFlush stdout
  traverse_ (T.hPutStrLn stderr) (reportErr report)
  hFlush stderr

-- | Every component the target asks for, and the project they were found
-- in, which is where the run's settings are read from as well.
componentsFor :: Palette -> Target -> IO (ProjectRoot, [Component])
componentsFor palette target =
  findProjectRoot "." >>= \case
    Nothing ->
      die 2 palette "no cabal.project or .cabal file at or above the working directory"
    Just root ->
      componentsOfTarget root target >>= \case
        Left problem ->
          die usageExitCode palette (describeTargetProblem problem)
        Right components ->
          pure (root, components)

-- | What @sysexits.h@ has called a usage error since 4.3BSD, and well clear
-- of the codes 'formatErrorExitCode' returns.
usageExitCode :: Int
usageExitCode = 64

-- | Give up, under the same mark a failed file wears.
die :: Int -> Palette -> Text -> IO a
die code palette why = do
  traverse_ (T.hPutStrLn stderr) (noted palette ("✗", Bad) why)
  System.Exit.exitWith (ExitFailure code)

-- | Print out the 'FormatError' and exit.
dieFormatting :: Palette -> FormatError -> IO a
dieFormatting palette e =
  die (formatErrorExitCode e) palette (describeFormatError palette e)

----------------------------------------------------------------------------
-- Command line options

-- | Command.
data Command
  = -- | Format the files of a component, or of all of them, in place.
    Inplace (Maybe String)
  | -- | Report what formatting the files of a component, or of all of them,
    -- would change.
    Check (Maybe String)
  | -- | Format the module read from standard input as the given file.
    ForEditor FilePath

-- | The command line options.
data Opts = Opts
  { -- | What to do.
    optCommand :: Command,
    -- | Whether to check AST equivalence.
    optCheckAst :: Choice "checkAst",
    -- | Whether to check idempotence.
    optCheckIdempotence :: Choice "checkIdempotence",
    -- | Whether to print debugging information about fixities.
    optDebugFixity :: Choice "debugFixity",
    -- | A build plan to trust as up to date.
    optBuildPlan :: Maybe FilePath,
    -- | Whether to read from and write to the cache.
    optUseCache :: Choice "useCache",
    -- | Whether to download sources that are missing.
    optDownload :: Choice "download",
    -- | Whether to count declined files as failed.
    optMustNotDecline :: Choice "mustNotDecline"
  }

optsParserInfo :: ParserInfo Opts
optsParserInfo =
  info (helper <*> versionOption <*> optsParser) . mconcat $
    [ fullDesc,
      progDesc "Format Haskell source code",
      header "tilia — a formatter for Haskell source code"
    ]
  where
    versionOption =
      infoOption
        ( "tilia "
            ++ showVersion version
            ++ "\nusing ghc-lib-parser "
            ++ ghcLibParserVersion
        )
        (long "version" <> short 'v' <> help "Print version of the program")

optsParser :: Parser Opts
optsParser =
  hsubparser . mconcat $
    [ command
        "inplace"
        ( info
            (parser (Inplace <$> optional targetArgument))
            (progDesc "Format files, in place")
        ),
      command
        "check"
        ( info
            (parser (Check <$> optional targetArgument))
            (progDesc "Report what formatting would change, and fail if anything would")
        ),
      command
        "for-editor"
        ( info
            (parser (ForEditor <$> fileArgument))
            (progDesc "Format a module read from standard input as FILE, and print it")
        )
    ]
  where
    parser cmd =
      Opts
        <$> cmd
        <*> checkAstSwitch
        <*> checkIdempotenceSwitch
        <*> debugFixitySwitch
        <*> optional buildPlanOption
        <*> noCacheSwitch
        <*> noDownloadsSwitch
        <*> mustNotDeclineSwitch
    checkAstSwitch =
      fromBool
        <$> (switch . mconcat)
          [ long "check-ast",
            help "Check AST equivalence"
          ]
    checkIdempotenceSwitch =
      fromBool
        <$> (switch . mconcat)
          [ long "check-idempotence",
            help "Check idempotence"
          ]
    debugFixitySwitch =
      fromBool
        <$> (switch . mconcat)
          [ long "debug-fixity",
            help "Print debugging information about fixities"
          ]
    buildPlanOption =
      (strOption . mconcat)
        [ long "build-plan",
          metavar "PLAN",
          help "Trust this plan.json as up to date rather than have Cabal solve one"
        ]
    noCacheSwitch =
      fromBool . not
        <$> (switch . mconcat)
          [ long "no-cache",
            help "Neither read from nor write to the cache"
          ]
    noDownloadsSwitch =
      fromBool . not
        <$> (switch . mconcat)
          [ long "no-downloads",
            help "Do not download sources that are missing"
          ]
    mustNotDeclineSwitch =
      fromBool
        <$> (switch . mconcat)
          [ long "must-not-decline",
            help "Fail on files that would otherwise be declined"
          ]
    targetArgument =
      (strArgument . mconcat)
        [ metavar "COMPONENT",
          help "Component to format: all (the default) or a package/component name"
        ]
    fileArgument =
      (strArgument . mconcat)
        [ metavar "FILE",
          help "File whose contents are read from standard input"
        ]