packages feed

floskell-0.9.0: src/main/Main.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}

-- | Main entry point to floskell.
module Main ( main ) where

import           Control.Applicative             ( (<|>), many, optional )
import           Control.Exception               ( catch, throw )

import           Data.Aeson
                 ( (.:?), (.=), FromJSON(..), ToJSON(..) )
import qualified Data.Aeson                      as JSON
import qualified Data.Aeson.Encode.Pretty        as JSON ( encodePretty )
import qualified Data.Aeson.Types                as JSON ( typeMismatch )
import qualified Data.ByteString                 as BS
import qualified Data.ByteString.Lazy            as BL
import qualified Data.HashMap.Lazy               as HashMap
import           Data.List                       ( inits, sort )
import           Data.Maybe                      ( isJust )
import           Data.Monoid                     ( (<>) )

import qualified Data.Text                       as T
import           Data.Version                    ( showVersion )

import           Floskell                        ( reformat, styles )
import           Floskell.Types
                 ( Style(styleName, styleInitialState) )

import           Foreign.C.Error                 ( Errno(..), eXDEV )

import           GHC.IO.Exception                ( ioe_errno )

import           Language.Haskell.Exts
                 ( Extension(..), Language(..), classifyExtension
                 , classifyLanguage, knownExtensions, knownLanguages )

import           Options.Applicative
                 ( ParseError(..), abortOption, argument, execParser, footerDoc
                 , fullDesc, header, help, helper, hidden, info, long, metavar
                 , option, progDesc, short, str, switch )
import qualified Options.Applicative.Help.Pretty as PP

import           Paths_floskell                  ( version )

import           System.Directory
                 ( XdgDirectory(..), copyFile, copyPermissions, doesFileExist
                 , findFileWith, getAppUserDataDirectory, getCurrentDirectory
                 , getHomeDirectory, getTemporaryDirectory, getXdgDirectory
                 , removeFile, renameFile )
import           System.FilePath                 ( joinPath, splitDirectories )
import           System.IO
                 ( FilePath, hClose, hFlush, openTempFile )

-- | Program options.
data Options = Options { optStyle       :: Maybe String
                       , optLanguage    :: Maybe String
                       , optExtensions  :: [String]
                       , optConfig      :: Maybe FilePath
                       , optPrintConfig :: Bool
                       , optFiles       :: [FilePath]
                       }

-- | Program configuration.
data Config = Config { cfgStyle      :: Style
                     , cfgLanguage   :: Language
                     , cfgExtensions :: [Extension]
                     }

instance ToJSON Config where
    toJSON Config{..} = JSON.object [ "style" .= styleName cfgStyle
                                    , "language" .= show cfgLanguage
                                    , "extensions" .= map showExt cfgExtensions
                                    , "formatting" .= styleInitialState cfgStyle
                                    ]
      where
        showExt (EnableExtension x) = show x
        showExt (DisableExtension x) = "No" ++ show x
        showExt (UnknownExtension x) = x

instance FromJSON Config where
    parseJSON (JSON.Object o) = do
        style <- maybe (cfgStyle defaultConfig) lookupStyle <$> o .:? "style"
        language <- maybe (cfgLanguage defaultConfig) lookupLanguage
            <$> o .:? "language"
        extensions <- maybe (cfgExtensions defaultConfig) (map lookupExtension)
            <$> o .:? "extensions"
        let flex = styleInitialState style
        flex' <- maybe flex (updateFlexConfig flex) <$> o .:? "formatting"
        let style' = style { styleInitialState = flex' }
        return $ Config style' language extensions
      where
        updateFlexConfig cfg v =
            case JSON.fromJSON $ mergeJSON (toJSON cfg) v of
                JSON.Error e -> error e
                JSON.Success x -> x

        mergeJSON JSON.Null r = r
        mergeJSON l JSON.Null = l
        mergeJSON (JSON.Object l) (JSON.Object r) =
            JSON.Object (HashMap.unionWith mergeJSON l r)
        mergeJSON _ r = r

    parseJSON v = JSON.typeMismatch "Config" v

-- | Default program configuration.
defaultConfig :: Config
defaultConfig = Config (head styles) Haskell2010 []

-- | Main entry point.
main :: IO ()
main = do
    opts <- execParser parser
    mconfig <- case optConfig opts of
        Just c -> return $ Just c
        Nothing ->
            if isJust (optStyle opts) then return Nothing else findConfig
    baseConfig <- case mconfig of
        Just path -> readConfig path
        Nothing -> return defaultConfig
    let config = mergeConfig baseConfig opts
    if optPrintConfig opts
        then BL.putStr $ JSON.encodePretty config
        else run config (optFiles opts)
  where
    parser = info (helper <*> versioner <*> options)
                  (fullDesc
                   <> progDesc "Floskell reformats one or more Haskell modules."
                   <> header "floskell - A Haskell Source Code Pretty Printer"
                   <> footerDoc (Just (footerStyles PP.<$$> footerLanguages
                                       PP.<$$> footerExtensions)))

    versioner = abortOption (InfoMsg $ "floskell " ++ showVersion version)
                            (long "version"
                             <> help "Display program version number" <> hidden)

    options = Options
        <$> optional (option str
                             (long "style" <> short 's' <> metavar "STYLE"
                              <> help "Formatting style"))
        <*> optional (option str
                             (long "language" <> short 'L' <> metavar "LANGUAGE"
                              <> help "Select base language"))
        <*> many (option str
                         (long "extension" <> short 'X' <> metavar "EXTENSION"
                          <> help "Enable or disable language extensions"))
        <*> optional (option str
                             (long "config" <> short 'c' <> metavar "FILE"
                              <> help "Configuration file"))
        <*> switch (long "print-config" <> help "Print configuration")
        <*> many (argument str
                           (metavar "FILES"
                            <> help "Input files (will be replaced)"))

    footerStyles =
        makeFooter "Supported styles:" (map (T.unpack . styleName) styles)

    footerLanguages =
        makeFooter "Supported languages:" (map show knownLanguages)

    footerExtensions =
        makeFooter "Supported extensions:"
                   [ show e | EnableExtension e <- knownExtensions ]

    makeFooter hdr xs =
        PP.empty PP.<$$> PP.text hdr PP.<$$> (PP.indent 2 . PP.fillSep
                                              . PP.punctuate PP.comma
                                              . map PP.text $ sort xs)

-- | Reformat files or stdin based on provided configuration.
run :: Config -> [FilePath] -> IO ()
run Config{..} files = case files of
    [] -> reformatStdin cfgStyle cfgLanguage cfgExtensions
    _ -> mapM_ (reformatFile cfgStyle cfgLanguage cfgExtensions) files

-- | Reformat stdin according to Style, Language, and Extensions.
reformatStdin :: Style -> Language -> [Extension] -> IO ()
reformatStdin style language extensions = BL.interact $
    reformatByteString style language extensions Nothing . BL.toStrict

-- | Reformat a file according to Style, Language, and Extensions.
reformatFile :: Style -> Language -> [Extension] -> FilePath -> IO ()
reformatFile style language extensions file = do
    text <- BS.readFile file
    tmpDir <- getTemporaryDirectory
    (fp, h) <- openTempFile tmpDir "floskell.hs"
    BL.hPutStr h $
        reformatByteString style language extensions (Just file) text
    hFlush h
    hClose h
    let exdev e = if ioe_errno e == Just ((\(Errno a) -> a) eXDEV)
                  then copyFile fp file >> removeFile fp
                  else throw e
    copyPermissions file fp
    renameFile fp file `catch` exdev

-- | Reformat a ByteString according to Style, Language, and Extensions.
reformatByteString :: Style
                   -> Language
                   -> [Extension]
                   -> Maybe FilePath
                   -> BS.ByteString
                   -> BL.ByteString
reformatByteString style language extensions mpath text =
    either error id $ reformat style language extensions mpath text

-- | Try to find a configuration file based on current working
-- directory, or in one of the application configuration directories.
findConfig :: IO (Maybe FilePath)
findConfig = do
    dotfilePaths <- sequence [ getHomeDirectory, getXdgDirectory XdgConfig "" ]
    dotfileConfig <- findFileWith doesFileExist dotfilePaths ".floskell.json"
    userPaths <- sequence [ getAppUserDataDirectory "floskell"
                          , getXdgDirectory XdgConfig "floskell"
                          ]
    userConfig <- findFileWith doesFileExist userPaths "config.json"
    localPaths <- map joinPath . reverse . drop 1 . inits . splitDirectories
        <$> getCurrentDirectory
    localConfig <- findFileWith doesFileExist localPaths "floskell.json"
    return $ localConfig <|> userConfig <|> dotfileConfig

-- | Load a configuration file.
readConfig :: FilePath -> IO Config
readConfig file = do
    text <- BS.readFile file
    either (error . (++) (file ++ ": ")) return $ JSON.eitherDecodeStrict text

-- | Update the program configuration from the program options.
mergeConfig :: Config -> Options -> Config
mergeConfig cfg@Config{..} Options{..} =
    cfg { cfgStyle      = maybe cfgStyle lookupStyle optStyle
        , cfgLanguage   = maybe cfgLanguage lookupLanguage optLanguage
        , cfgExtensions = cfgExtensions ++ map lookupExtension optExtensions
        }

-- | Lookup a style by name.
lookupStyle :: String -> Style
lookupStyle name = case filter ((== T.pack name) . styleName) styles of
    [] -> error $ "Unknown style: " ++ name
    x : _ -> x

-- | Lookup a language by name.
lookupLanguage :: String -> Language
lookupLanguage name = case classifyLanguage name of
    UnknownLanguage _ -> error $ "Unknown language: " ++ name
    x -> x

-- | Lookup an extension by name.
lookupExtension :: String -> Extension
lookupExtension name = case classifyExtension name of
    UnknownExtension _ -> error $ "Unkown extension: " ++ name
    x -> x