packages feed

boilerplate-0.0.3: exe/Main.hs

{-# LANGUAGE CPP #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TupleSections #-}

module Main where

import Boilerplate.ConfigParser
import Boilerplate.Doc
import Boilerplate.GhcParser
import Boilerplate.Interpreter
import Boilerplate.RuleFinder
import Boilerplate.RuleParser
import Boilerplate.Types
#if MIN_VERSION_GLASGOW_HASKELL(9,0,0,0)
import qualified GHC.Version as GHC
#else
import qualified Config as GHC
#endif
import Control.Monad (when)
import Data.IORef (modifyIORef')
import Data.IORef (readIORef)
import Data.IORef (newIORef)
import Data.List (isPrefixOf)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as M
import Data.Maybe (mapMaybe)
import Data.Maybe (catMaybes)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.IO as T
import HsInspect.Types (Comment(..), Pos(..), Type(..))
import System.Environment (getArgs)
import System.Exit (ExitCode(..), exitWith)
import System.IO (stderr)
import System.IO (hPutStrLn)
import Text.Parsec (parse, setPosition)
import Text.Parsec.Pos (newPos)
import Text.Parsec.Text (parseFromFile)

version :: String
#ifdef CURRENT_PACKAGE_VERSION
version = CURRENT_PACKAGE_VERSION
#else
version = "unknown"
#endif

help :: String
help =
  "boilerplate [GLOBAL FLAGS] FILE\n\n" ++
  "Global flags:\n\n" ++
  " -h,--help               Show this help text\n" ++
  " -v,--version            Print version information\n" ++
  "    --ghc-version        Print ghc version information\n" ++
  " -i,--inplace            Overwrite FILE in place\n" ++
  "    --verbose            Print debugging information to stderr\n" ++
  "    --check-rule         Parse FILE as a rule file and display errors\n"

-- TODO automatic imports
--
-- rules could declare imports they probably need quite easily, but figuring out
-- if an import is necessary (especially considering reexports) and making a
-- diff to the import section is very difficult and probably warrants its own
-- tool. Nevertheless, this would be an excellent feature.

main :: IO ()
main = do
  args <- getArgs
  let hasAny :: Eq a => [a] -> [a] -> Bool
      hasAny [] _ = False
      hasAny _ [] = False
      hasAny as (x : xs) | elem x as = True
                         | otherwise = hasAny as xs

  when (hasAny ["-h", "--help"] args) $
    (putStrLn help) >> exitWith ExitSuccess
  when (hasAny ["-v", "--version"] args) $
    (putStrLn version) >> exitWith ExitSuccess
  when (elem "--ghc-version" args) $
    (putStrLn GHC.cProjectVersion) >> exitWith ExitSuccess

  let exit1 msg = (hPutStrLn stderr msg) >> (exitWith $ ExitFailure 1)

  when (null args) $ exit1 "missing arguments"

  let inplace = hasAny ["-i", "--inplace"] args
      file = last $ filter (not . isPrefixOf "-") args
      verbose = elem "--verbose" args

  -- TODO all parsec errors should be formatted as path/to/file.hs:line:col:

  when (elem "--check-rule" args) $ do
    parsed <- parseFromFile ruleParser file
    case parsed of
      Left err -> exit1 $ show err
      Right _ -> exitWith ExitSuccess

  content <- T.readFile file

  -- early exit when there is no work to do
  when (not $ T.isInfixOf "BOILERPLATE" content) $
    if inplace
    then exitWith ExitSuccess
    else (T.putStr content) >> exitWith ExitSuccess

  (types, comments) <- parseHaskell file

  let parseComment (Comment txt (Pos line col) end) =
        case parse (setPosition (newPos file line col) *> configCommentParser) file txt of
        Left msg -> do
          when (verbose && T.isInfixOf "BOILERPLATE" txt) $ do
            hPutStrLn stderr $ show msg
          pure $ Nothing
        Right c -> pure $ Just (c, end)

  configs <- catMaybes <$> traverse parseComment comments
  let targets = flip mapMaybe (threes configs) $ \case
        (One   ((Config t r c), s)) -> Just $ Action t r c s Nothing
        (Two   ((Config t r c), s) _) -> Just $ Action t r c s Nothing
        -- the ConfigStart is irrelevant for position detection, it is
        -- to make it easier for text editors to hide expansions.
        (Three ((Config t r c), s) (ConfigStart, _) (ConfigEnd, e)) -> Just $ Action t r c s (Just e)
        (Three ((Config t r c), s) _ _) -> Just $ Action t r c s Nothing
        _ -> Nothing

  ruleFiles <- findRules file

  when verbose $ do
    hPutStrLn stderr $ show types
    hPutStrLn stderr $ show targets
    hPutStrLn stderr $ show ruleFiles

  rulesCache <- newIORef M.empty

  let
    findRule :: Text -> IO Rule
    findRule rulename = do
      let pickRule (fqn, short, path) =
            if fqn == rulename || short == rulename
            then Just path
            else Nothing
      case mapMaybe pickRule ruleFiles of
        [] -> exit1 "no rules"
        [ruleFile] -> do
          cached <- readIORef rulesCache
          case M.lookup ruleFile cached of
            Nothing -> do
              parsed <- parseFromFile ruleParser ruleFile
              case parsed of
                Left err -> exit1 $ show err
                Right r -> r <$ modifyIORef' rulesCache (M.insert ruleFile r)
            Just hit -> do
              when verbose $
                hPutStrLn stderr $ "hit cache for " <> ruleFile
              pure hit
        many -> exit1 $ "ambiguous rules" <> show many

    tpes :: Map Text Type
    tpes = M.fromList $ (\t -> (tycon t, t)) <$> types
      where
        tycon = \case
          ProductType tc _ _ _ _ -> tc
          RecordType tc _ _ _ _ -> tc
          SumType tc _ _ -> tc

    resolve (Action tycon rulenames custom start end) = do
      tpe <- case M.lookup tycon tpes of
        Nothing -> exit1 $ "could not find the type definition for " <> show tycon
        Just a -> pure a
      rules' <- traverse findRule rulenames
      pure $ Action tpe rules' custom start end

  resolved <- traverse resolve targets

  when verbose $ do
    hPutStrLn stderr $ show resolved

  let
    interpret (Action tpe rules cus start end) =
      (\txt -> (start, end, prepare $ T.unlines txt)) <$>
        (traverse (\r -> interpretRule r tpe (M.fromList cus)) rules)
    interpreted = traverse interpret resolved
    prepare snippet = start_comment <> T.strip snippet <> end_comment
    start_comment = "\n{- BOILERPLATE START -}\n"
    end_comment = "\n{- BOILERPLATE END -}"

  replacement <- unDoc <$> case interpreted of
    Left err -> exit1 $ T.unpack err
    Right good -> pure $ upsertMany (mkDoc content) good

  if inplace
  then T.writeFile file replacement
  else T.putStr replacement

data Three a = One a | Two a a | Three a a a

threes :: [a] -> [Three a]
threes = \case
  [] -> []
  [a] -> [One a]
  a1 : a2 : [] -> Two a1 a2 : (threes [a2])
  a1 : a2 : a3 : as -> Three a1 a2 a3 : (threes $ a2 : a3 : as)

type Target = Action Text Text
type Resolved = Action Type Rule

data Action tpe rule = Action tpe [rule] [(Text, Custom)] Pos (Maybe Pos)
  deriving (Eq, Show)