packages feed

haskell-docs-cli-1.0.0.0: src/Docs/CLI/Evaluate.hs

{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE TypeApplications #-}
{-# OPTIONS_GHC -Wwarn #-}
module Docs.CLI.Evaluate
  ( interactive
  , evaluate
  , evaluateCmd
  , ShellState(..)
  , Context(..)
  , Cmd(..)
  , Selection(..)
  , View(..)
  , HackageUrl(..)
  , HoogleUrl(..)
  , runCLI
  , defaultHoogleUrl
  , defaultHackageUrl
  , moreInfoText
  ) where

import Prelude hiding (mod)
import Control.Applicative ((<|>))
import Control.Exception (finally, throwIO, try, handle, SomeException)
import Control.Monad (unless, void)
import Control.Monad.Trans.Class (lift)
import Control.Monad.Except (ExceptT(..), MonadError, catchError, runExceptT, throwError)
import Control.Monad.Catch (MonadThrow)
import Control.Monad.State.Lazy (MonadState, StateT(..))
import Data.Foldable (toList)
import Data.Function (on)
import Data.Functor ((<&>))
import Data.List.NonEmpty (NonEmpty)
import Control.Monad.IO.Class (MonadIO, liftIO)
import Text.Read (readMaybe)
import Data.Maybe (fromMaybe, mapMaybe, listToMaybe)
import Data.List hiding (groupBy)
import Data.List.Extra (breakOn)
import Data.Char (isSpace)
import System.Directory (getHomeDirectory)
import System.Environment (getEnv, lookupEnv)
import System.IO (hPutStrLn, hClose, hFlush, stdout, Handle, stderr)
import System.IO.Temp (withSystemTempFile)
import System.Exit (exitSuccess)
import qualified Hoogle as H
import System.FilePath ((</>))
import Network.URI (uriToString)

import Docs.CLI.Directory
import Docs.CLI.Types
import Docs.CLI.Haddock as Haddock
import qualified Docs.CLI.Hoogle as Hoogle
import Data.Cache

import qualified Control.Concurrent.Async as Async
import qualified Control.Concurrent.MVar as MVar
import qualified Control.Monad.State.Lazy as State
import qualified Data.Aeson as Aeson
import qualified Data.ByteString.Lazy as LB
import qualified Data.List.NonEmpty as NonEmpty
import qualified Data.Map.Strict as Map
import qualified Data.Set as Set
import qualified Data.Text as Text
import qualified Data.Text.Encoding as Text
import qualified Data.Text.IO as Text (hPutStr)
import qualified Network.HTTP.Client as Http
import qualified Network.HTTP.Types.Status as Http
import qualified System.Console.Haskeline as CLI
import qualified System.Process as Process
import qualified System.Console.Terminal.Size as Terminal
import qualified Text.PrettyPrint.ANSI.Leijen as P


data ShellState = ShellState
  { sContext :: Context
  , sManager :: Http.Manager
  , sCache :: Cache
  , sNoColours :: Bool
  , sHoogle :: HoogleUrl
  , sHackage :: HackageUrl
  }

type TargetGroup = NonEmpty Hoogle.Item

-- | Context referenced by commands that contain an index
data Context
  = ContextEmpty                        -- ^ Nothing selected
  | ContextSearch String [TargetGroup]  -- ^ within search results
  | ContextModule Haddock.Module        -- ^ looking at module docs
  | ContextPackage Haddock.Package      -- ^ looking at a a package's modules

type Index = Int

-- | Commands we accept
data Cmd
  = ViewAny View Selection
  -- ^ by default we do a Hoogle search or view/index the current context
  | ViewDeclarationSource Selection
  | ViewDeclaration Selection
  | ViewModule View Selection
  | ViewPackage View Selection
  | Help
  | Quit

data Selection
  = SelectContext
  | SelectByIndex Index
  | SelectByPrefix String
  | Search String

data View =  Interface | Documentation

newtype M a = M { runM :: ExceptT String (CLI.InputT (StateT ShellState IO)) a }
  deriving newtype
    ( Functor
    , Applicative
    , Monad
    , MonadError String
    , MonadIO
    , MonadThrow
    , MonadFail
    )

instance MonadState ShellState M where
  state f = M $ lift $ lift $ State.state f

newtype HoogleUrl = HoogleUrl Url

newtype HackageUrl = HackageUrl Url

defaultHoogleUrl :: HoogleUrl
defaultHoogleUrl =
  HoogleUrl "https://hoogle.haskell.org"

defaultHackageUrl :: HackageUrl
defaultHackageUrl =
  HackageUrl "https://hackage.haskell.org"

runCLI :: ShellState -> M a -> IO (Either String a)
runCLI state program = do
  settings <- cliSettings
  flip State.evalStateT state
    $ CLI.runInputT settings
    $ CLI.withInterrupt
    $ runExceptT
    $ runM program

cliSettings :: IO (CLI.Settings (StateT ShellState IO))
cliSettings = do
  mHistFile <- either (const Nothing) Just <$> try @SomeException getAppHistoryFile
  return $ def
    { CLI.complete = complete
    , CLI.historyFile = mHistFile
    }
  where
    def :: CLI.Settings (StateT ShellState IO)
    def = CLI.defaultSettings

complete :: CLI.CompletionFunc (StateT ShellState IO)
complete (left', _) = do
  let left = reverse left'
  context <- State.gets sContext
  let options = case context of
        ContextEmpty -> []
        ContextSearch _ tgroups -> map (completion . NonEmpty.head) tgroups
        ContextModule m -> map completion (mDeclarations m)
        ContextPackage p -> pModules p

      asCompletion prefix option =
        CLI.Completion
          { CLI.replacement = drop (length prefix) option
          , CLI.display = option
          , CLI.isFinished = True
          }

      dropEnd n = reverse . drop n . reverse

      -- drop from begining til after the infix
      -- quadratic, but only executed in one string so doesn't
      -- matter very much
      dropInfix _ [] = []
      dropInfix inf (_:ys) =
        if inf `isPrefixOf` ys
          then drop (length inf) ys
          else dropInfix inf ys

      completionsFor :: String -> String -> (String , [CLI.Completion])
      completionsFor l xs
        | cs@(_:_) <- filter (xs `isPrefixOf`) options =
          (l, map (asCompletion xs) cs)
        | Just option <- find (xs `isInfixOf`) options =
          let newPrefix = dropEnd (length $ dropInfix xs option) option
              newLeft = reverse newPrefix <> dropWhile (/= '/') l
          in completionsFor newLeft newPrefix
        | otherwise = (l, [])

  return $ case left of
    ':':xs | not (any isSpace xs) , Just cinfo <- cmdInfoFromPrefix xs ->
      (":", [CLI.simpleCompletion $ commandName cinfo])
    ':':xs | (_, ' ':'/':prefix) <- break isSpace xs ->
      completionsFor left' prefix
    '/':xs ->
      completionsFor left' xs
    _ ->
      (left', [])

class MonadCLI m where
  getInputLine :: String -> m (Maybe String)

instance MonadCLI M where
  getInputLine str = M $ lift $ CLI.getInputLine str

runSearch :: String -> M [Hoogle.Item]
runSearch term = do
  HoogleUrl url <- State.gets sHoogle
  req <- Http.parseRequest url
    <&> Http.setQueryString
      [ ("mode", Just "json")
      , ("start", Just "1")
      , ("hoogle", Just $ Text.encodeUtf8 $ Text.pack term)
      ]
  res <- fetch req
  either error return $ Aeson.eitherDecode res

withFirstSearchResult
  :: (String, Hoogle.Item -> Maybe x)
  -> String
  -> (x -> M a)
  -> M a
withFirstSearchResult (name, get) term act = do
  allResults <- runSearch term
  let res = toGroups allResults
  State.modify' (\s -> s{ sContext = ContextSearch term res })
  case listToMaybe (mapMaybe get allResults) of
    Just firstValid ->
      act firstValid
    Nothing -> do
      viewSearchResults res
      throwError $ "No " <> name <> " results found for '" <> term <> "'"

packageUrl :: HackageUrl -> String -> PackageUrl
packageUrl (HackageUrl hackage) pname =
  PackageUrl $ hackage ++ "/package/" ++ pname

toGroups :: [Hoogle.Item] -> [TargetGroup]
toGroups
  = mapMaybe NonEmpty.nonEmpty
  . groupBy relevantFields
  where
    relevantFields item = target
      { H.targetURL = ""
      , H.targetPackage = Nothing
      , H.targetModule = Nothing
      }
      where
        target = case item of
            Hoogle.Declaration x -> Hoogle.dTarget x
            Hoogle.Module x      -> Hoogle.mTarget x
            Hoogle.Package x     -> Hoogle.pTarget x

groupBy :: Ord b => (a -> b) -> [a] -> [[a]]
groupBy f vs = go mempty vs
  where
    go res []
      = map reverse
      $ reverse
      $ fst
      $ foldr takeOnce ([], res)
      $ reverse vs
    go res (x:xs) = go newRes xs
      where newRes = Map.insertWith (++) (f x) [x] res

    takeOnce x (out, m) =
      let key = f x in
      case Map.lookup key m of
        Nothing -> (out, m)
        Just v -> (v:out, Map.delete key m)

newtype CmdInfo = CmdInfo (String, Selection -> Cmd, P.Doc)

commandName :: CmdInfo -> String
commandName (CmdInfo (name, _,_)) = takeWhile (not . isSpace) name

commands :: [CmdInfo]
commands = map CmdInfo
  --any
  [ ("documentation <selector>",
        ViewAny Documentation,
        "") -- this can come out?
  , ("interface <selector>",
        ViewAny Interface,
        "" )
  , ("src <selector>",
        ViewDeclarationSource,
        "View the source code of a function or type" <> P.linebreak
        <> "Set the editor with the 'EDITOR' environment variable.")
  -- declaration
  , ("declaration <selector>",
        ViewDeclaration,
        "View the Hackage documentation for a function or type")
  , ("ddocumentation <selector>",
        ViewDeclaration,
        "Alias of :declaration")
  -- module
  , ("module <selector>",
        ViewModule Documentation,
        "View documentation for a module matching a selector")
  , ("mdocumentation <selector>",
        ViewModule Documentation,
        "Alias of :module")
  , ("minterface <selector>",
        ViewModule Interface,
        "View a module's interface")
  -- package
  , ("package <selector>",
        ViewPackage Documentation,
        "View documentation for a package matching a selector")
  , ("pdocumentation <selector>",
        ViewPackage Documentation,
        "Alias of :package")
  , ("pinterface <selector>",
        ViewPackage Interface,
        "View a package's interface")
  , ("help",
        const Help,
        "View this help text")
  , ("quit",
        const Quit,
        "Exit the program")
  ]

cmdInfoFromPrefix :: String -> Maybe CmdInfo
cmdInfoFromPrefix v = find (\cmd -> v `isPrefixOf` commandName cmd) commands

parseCommand :: String -> Either String Cmd
parseCommand str = case str of
  (':':xs) -> do
    let (typedCommand, args) = drop 1 <$> break isSpace xs
        selection
          | null args                = SelectContext
          | ('/':prefix) <- args     = SelectByPrefix prefix
          | Just n <- readMaybe args = SelectByIndex n
          | otherwise                = Search args
    case cmdInfoFromPrefix  typedCommand of
      Just (CmdInfo (_, toCmd, _)) -> Right (toCmd selection)
      Nothing -> Left "*** Unknown command. Type :help for help."
  -- no colon cases
  ('/':prefix)              -> Right $ ViewAny Interface $ SelectByPrefix prefix
  x | Just n <- readMaybe x -> Right $ ViewAny Interface $ SelectByIndex n
  []                        -> Right $ ViewAny Interface SelectContext
  _                         -> Right $ ViewAny Interface $ Search str

interactive :: M ()
interactive = do
  viewInTerminal greeting
  loop $ do
    printContext
    input <- fromMaybe ":quit" <$> getInputLine "> "
    evaluate input
  where
    onError = return $ Right ()

    loop action = tryM action >> loop action

    tryM :: M () -> M ()
    tryM = M. ExceptT . CLI.handleInterrupt onError . runExceptT . runM

    printContext = do
      context <- State.gets sContext
      case context of
        ContextEmpty      -> return ()
        ContextSearch t _ -> viewInTerminal $ P.text $ "search: " <> t
        ContextModule m   -> viewInTerminal $ P.text $ "module: " <> mTitle m
        ContextPackage p  -> viewInTerminal $ P.text $ "package: " <> pTitle p

greeting :: P.Doc
greeting = P.vcat
  [ P.black "---- "
      <> P.blue "haskell-docs-cli"
      <> P.black " ----------------------------------------------------------"
  , P.black "Say :help for help and :quit to exit"
  , P.black "--------------------------------------------------------------------------------"
  ]

evaluate :: String -> M ()
evaluate input =
  case parseCommand input of
    Left err   -> liftIO (putStrLn err)
    Right cmd  -> evaluateCmd cmd `catchError` showFailure
  where
    showFailure e = liftIO $ hPutStrLn stderr $ "Failed: "<> e

evaluateCmd :: Cmd -> M ()
evaluateCmd cmd = State.gets sContext >>= \context -> case cmd of
  Help -> viewInTerminal helpText
  Quit -> liftIO exitSuccess

  -- pressed enter without typing anything
  ViewAny Interface SelectContext ->
    case context of
      ContextEmpty            -> return ()
      ContextSearch _ results -> viewSearchResults results
      ContextModule mdocs     -> viewModuleInterface mdocs
      ContextPackage package  -> viewPackageInterface package

  -- <TERM>
  ViewAny Interface (Search term) -> do
    res <- toGroups <$> runSearch term
    viewSearchResults res
    State.modify' $ \s -> s{ sContext = ContextSearch term res }

  -- <INDEX>
  ViewAny Interface (SelectByIndex ix) ->
    case context of
      ContextEmpty       -> errEmptyContext
      ContextSearch _ xs -> withIx ix xs viewTargetGroup
      ContextModule m    -> do withIx ix (mDeclarations m) viewDeclarationWithLink
      ContextPackage p   -> withModuleFromPackageIx ix p viewModuleInterface

  -- /<PREFIX>
  ViewAny Interface (SelectByPrefix pre) ->
    case context of
      ContextEmpty       -> errEmptyContext
      ContextSearch _ xs -> withPrefix pre xs viewTargetGroup
      ContextModule m    -> withPrefix pre (mDeclarations m) viewDeclarationWithLink
      ContextPackage p   -> withPrefix pre (pModules p) $ \m ->
          withModuleFromPackage m p viewModuleInterface

  -- :documentation
  ViewAny Documentation SelectContext ->
    case context of
      ContextEmpty            -> errEmptyContext
      ContextSearch _ results -> viewSearchResults results
      ContextModule mod       -> viewModuleDocs mod
      ContextPackage package  -> viewPackageDocs package

  -- :documentation <TERM>
  ViewAny Documentation (Search term) ->
    withFirstSearchResult moduleResult term $ \hmod ->
    withModule (Hoogle.mUrl hmod) viewModuleDocs

  -- :documentation <INDEX>
  ViewAny Documentation (SelectByIndex ix) ->
    case context of
      ContextEmpty       -> errEmptyContext
      ContextSearch _ xs -> withIx ix xs targetGroupDocumentation
      ContextModule m    -> withDeclFromModuleIx ix m viewDeclaration
      ContextPackage p   -> withModuleFromPackageIx ix p viewModuleDocs

  -- :documentation /<PREFIX>
  ViewAny Documentation (SelectByPrefix pre) ->
    case context of
      ContextEmpty       -> errEmptyContext
      ContextSearch _ xs -> withPrefix pre xs targetGroupDocumentation
      ContextModule m    -> withPrefix pre (mDeclarations m) viewDeclaration
      ContextPackage p   -> withPrefix pre (pModules p) $ \m ->
          withModuleFromPackage m p viewModuleDocs
  -- :src
  ViewDeclarationSource SelectContext ->
    throwError "no declaration selected. Use ':src INT'"

  -- :src <TERM>
  ViewDeclarationSource (Search term) ->
    withFirstSearchResult declResult term (viewSource . Hoogle.dUrl)

  -- :src <INDEX>
  ViewDeclarationSource (SelectByIndex ix) ->
    case context of
      ContextEmpty       -> errEmptyContext
      ContextSearch _ xs -> withIx ix xs $
          maybe errNoSourceAvailable (viewSource . Hoogle.dUrl) . toDecl . NonEmpty.head
      ContextModule m    -> withDeclFromModuleIx ix m (viewSource . declUrl)
      ContextPackage _   -> errSourceOnlyForDeclarations

  -- :src <INDEX>
  ViewDeclarationSource (SelectByPrefix pre) ->
    case context of
      ContextEmpty       -> errEmptyContext
      ContextSearch _ xs -> withPrefix pre xs $
          maybe errNoSourceAvailable (viewSource . Hoogle.dUrl) . toDecl . NonEmpty.head
      ContextModule m    -> withPrefix pre (mDeclarations m) (viewSource . declUrl)
      ContextPackage _   -> errSourceOnlyForDeclarations

  -- :declaration
  -- :ddocumentation
  ViewDeclaration SelectContext ->
    throwError "no declaration selected."

  -- :declaration <TERM>
  ViewDeclaration (Search term) ->
    withFirstSearchResult declResult term $ \hdecl ->
    let tgroup = Hoogle.Declaration hdecl NonEmpty.:| []
    in targetGroupDocumentation tgroup

  -- :declaration <INDEX>
  ViewDeclaration (SelectByIndex ix) ->
    case context of
      ContextEmpty       -> errEmptyContext
      ContextSearch _ xs -> withIx ix xs viewTargetGroup
      ContextModule m    -> withDeclFromModuleIx ix m viewDeclaration
      ContextPackage _   -> errNotDeclarationButModule

  -- :declaration /<prefix>
  ViewDeclaration (SelectByPrefix pre) ->
    case context of
      ContextEmpty       -> errEmptyContext
      ContextSearch _ xs -> withPrefix pre xs viewTargetGroup
      ContextModule m    -> withPrefix pre (mDeclarations m) viewDeclaration
      ContextPackage _   -> errNotDeclarationButModule

  -- :minterface
  -- :mdocumentation
  ViewModule view SelectContext ->
    case context of
      ContextModule mod -> viewModule view mod
      _                 -> throwError "not in a module context"

  -- :minterface <TERM>
  -- :mdocumentation <TERM>
  ViewModule view (Search term) ->
    withFirstSearchResult moduleResult term $ \hmod ->
    withModule (Hoogle.mUrl hmod) $ \mod ->
    viewModule view mod

  -- :minterface <INDEX>
  -- :mdocumentation <INDEX>
  ViewModule view (SelectByIndex ix) ->
    case context of
      ContextEmpty       -> errEmptyContext
      ContextSearch _ xs -> withIx ix xs $ withModuleForTargetGroup $ viewModule view
      ContextModule m    -> viewModule view m
      ContextPackage p   -> withModuleFromPackageIx ix p (viewModule view)

  -- :minterface /<PREFIX>
  -- :mdocumentation /<PREFIX>
  ViewModule view (SelectByPrefix pre) ->
    case context of
      ContextEmpty       -> errEmptyContext
      ContextSearch _ xs -> withPrefix pre xs $ withModuleForTargetGroup $ viewModule view
      ContextModule m    -> viewModule view m
      ContextPackage p   -> withPrefix pre (pModules p) $ \mod ->
        withModuleFromPackage mod p (viewModule view)

  -- :pinterface
  -- :pdocumentation
  ViewPackage view SelectContext ->
    case context of
      ContextPackage package ->
        viewPackage view package
      ContextModule mod ->
        withPackageForModule mod (viewPackage view)
      _ -> throwError "not in a package context"

  -- :pinterface <TERM>
  -- :pdocumentation <TERM>
  ViewPackage view (Search term) -> do
    hackage <- State.gets sHackage
    let url = packageUrl hackage term
    html <- fetchHTML url
    let package = parsePackageDocs url html
    State.modify' $ \s -> s{ sContext = ContextPackage package }
    viewPackage view package

  -- :pinterface <INDEX>
  -- :pdocumentation <INDEX>
  ViewPackage view (SelectByIndex ix) ->
    case context of
      ContextEmpty       -> errEmptyContext
      ContextSearch _ xs -> withIx ix xs $ withPackageForTargetGroup (viewPackage view)
      ContextModule m    -> withPackageForModule m (viewPackage view)
      ContextPackage p   -> viewPackage view p

  -- :pinterface /<PREFIX>
  -- :pdocumentation /<PREFIX>
  ViewPackage view (SelectByPrefix pre) ->
    case context of
      ContextEmpty       -> errEmptyContext
      ContextSearch _ xs -> withPrefix pre xs $ withPackageForTargetGroup $ viewPackage view
      ContextModule m    -> withPackageForModule m (viewPackage view)
      ContextPackage p   -> viewPackage view p

moreInfoText :: P.Doc
moreInfoText =
  "More info at <https://github.com/lazamar/haskell-docs-cli>"

helpText :: P.Doc
helpText = P.vcat $ concatMap addLine
  [ hcommands
  , hselectors
  , hexamples
  , moreInfoText
  ]
  where
    addLine :: P.Doc -> [P.Doc]
    addLine line = [line, ""]

    showItems :: [(String, P.Doc)] -> P.Doc
    showItems items =
      let maxNameWidth = maximum $ map (length . fst) items in
      P.indent 2 $ P.vcat
        [ P.fillBreak (maxNameWidth + 2) (P.pretty name) P.<+> P.align description
        | (name,description) <- items ]

    hcommands =  P.vcat
      [ "Commands:"
      , showItems [(":" <> cmd, txt) | CmdInfo (cmd,_,txt) <- commands ]
      ]

    hselectors = P.vcat
      [ "Selectors:"
      , showItems
          [ ("<int>", "select an option by index")
          , ("/<str>", "select an option by prefix")
          , ("<str>", "search for an option")
          ]
      ]

    hexamples = P.vcat
      [ "Examples:"
      , showItems
          [ ("takeWhile", "View Hoogle search results for 'takeWhile'")
          , (":package containers", "View package documentation for the 'containers' package")
          , (":module Data.List", "View module documentation for the 'Data.List' module")
          , (":src insertWith", "View the source for the first Hoogle result for 'insertWith'")
          , (":package 2"
            , "View package documentation for the item with index 2 in the" P.</> "current context"
            )
          , (":module /tak"
            , "View module documentation for the first item with prefix" P.</> "'tak' in the current context"
            )
          ]
      ]
targetGroupDocumentation :: TargetGroup -> M ()
targetGroupDocumentation tgroup = do
  let item = NonEmpty.head tgroup
  context <- State.gets sContext
  case item of
    Hoogle.Module hmod ->
      withModule (Hoogle.mUrl hmod) viewModuleDocs
    Hoogle.Package pkg ->
      withPackage (Hoogle.pUrl pkg) viewPackageDocs
    Hoogle.Declaration d ->
      withModule (Hoogle.dModuleUrl d) $ \mod -> do
      State.modify' $ \ s -> s { sContext = context }
      viewInTerminalPaged $ case targetDeclaration d mod of
        Just decl -> prettyDecl decl
        Nothing   -> viewDescription item

-- errors
errSourceOnlyForDeclarations :: M a
errSourceOnlyForDeclarations =
    throwError "can only view source of declarations"

errEmptyContext :: M a
errEmptyContext =
  throwError "empty context"

errNoSourceAvailable :: M a
errNoSourceAvailable =
 throwError "no source available for that declaration"

errNotDeclarationButModule :: M a
errNotDeclarationButModule =
  throwError "item at index is not a declaration; it is a module."

targetDeclaration :: Hoogle.Declaration -> Module -> Maybe Declaration
targetDeclaration decl = lookupDecl anchor
  where
    DeclUrl _ anchor = Hoogle.dUrl decl

withModule
  :: ModuleUrl
  -> (Module -> M a)
  -> M a
withModule url act = do
  html <- fetchHTML url
  let mod = parseModuleDocs url html
  State.modify' $ \s -> s{ sContext = ContextModule mod }
  act mod

withPackage :: PackageUrl -> (Package -> M a) -> M a
withPackage url act = do
  html <- fetchHTML url
  let package = parsePackageDocs url html
  State.modify' $ \s -> s{ sContext = ContextPackage package }
  act package

withPackageForModule :: Module -> (Package -> M a) -> M a
withPackageForModule mod act = do
  let url = toPackageUrl $ mUrl mod
  html <- fetchHTML url
  let package = parsePackageDocs url html
  State.modify' $ \s -> s{ sContext = ContextPackage package }
  act package

-- | Get an element matching a prefix
withPrefix :: HasCompletion a => String -> [a] -> (a -> M b) -> M b
withPrefix pre values act =
  let prefix = reverse $ dropWhile isSpace $ reverse pre
  in
  case find ((prefix `isPrefixOf`) . completion) values of
    Nothing -> throwError "No item matching prefix"
    Just res -> act res

-- | Get an element from a one-indexed index
withIx :: Int -> [a] -> (a -> M b) -> M b
withIx ix xs act =
  maybe (throwError "index out of range") act
  $ listToMaybe
  $ drop (ix - 1) xs

withPackageForTargetGroup :: (Package -> M a) -> TargetGroup -> M a
withPackageForTargetGroup act tgroup = do
  purl <- selectPackage tgroup
  withPackage purl act
  where
    selectPackage :: TargetGroup -> M PackageUrl
    selectPackage
      = promptSelectOne
      . nubBy ((==) `on` fst)
      . map f
      . toList

    f :: Hoogle.Item -> (PackageUrl, P.Doc)
    f x = case x of
      Hoogle.Module m      -> (Hoogle.mPackageUrl m, viewItemPackage x)
      Hoogle.Declaration d -> (Hoogle.dPackageUrl d, viewItemPackage x)
      Hoogle.Package p     -> (Hoogle.pUrl p       , viewItemPackage x)

withModuleForTargetGroup :: (Module -> M a) -> TargetGroup -> M a
withModuleForTargetGroup act tgroup = do
  murl <- selectModule tgroup
  withModule murl act
  where
    selectModule :: TargetGroup -> M ModuleUrl
    selectModule
      = promptSelectOne
      . mapMaybe f
      . toList

    f :: Hoogle.Item -> Maybe (ModuleUrl, P.Doc)
    f x = case x of
      Hoogle.Module m      -> Just (Hoogle.mUrl m, viewItemPackageAndModule x)
      Hoogle.Declaration d -> Just (Hoogle.dModuleUrl d, viewItemPackageAndModule x)
      Hoogle.Package _     -> Nothing

promptSelectOne :: [(a, P.Doc)] -> M a
promptSelectOne = \case
  []      -> throwError "No matching options"
  [(x,_)] -> return x
  xs      -> do
    liftIO $ putStrLn "Select one:"
    viewInTerminal $ P.vsep $ numbered $ map snd xs
    num <- getInputLine ": "
    case readMaybe =<< num of
      Just n -> case listToMaybe $ drop (n - 1) xs of
        Just (x, _) -> return x
        Nothing -> do
          liftIO $ hPutStrLn stderr "Invalid index"
          promptSelectOne xs
      Nothing -> do
        liftIO $ hPutStrLn stderr "Number not recognised"
        promptSelectOne xs

withModuleFromPackage :: String -> Package -> (Module -> M a) -> M a
withModuleFromPackage modName Package{..} act = do
  let url = packageModuleUrl pUrl modName
  html <- fetchHTML url
  let mod = parseModuleDocs url html
  State.modify' $ \s -> s{ sContext = ContextModule mod }
  act mod

withModuleFromPackageIx :: Int -> Package -> (Module -> M a) -> M a
withModuleFromPackageIx ix p act =
  withIx ix (pModules p) $ \m -> withModuleFromPackage m p act

withDeclFromModuleIx :: Int -> Module -> (Declaration -> M a) -> M a
withDeclFromModuleIx ix = withIx ix . mDeclarations

viewSearchResults :: [TargetGroup] -> M ()
viewSearchResults
  = viewInTerminal
  . P.vsep
  . reverse
  . numbered
  . map viewSummary

viewDeclaration :: Declaration -> M ()
viewDeclaration = viewInTerminalPaged . prettyDecl

viewDeclarationWithLink :: Declaration -> M ()
viewDeclarationWithLink decl = viewInTerminalPaged $ P.vcat
  [ prettyDecl decl
  , Haddock.link $ P.text $ getUrl (dDeclUrl decl)
  ]

viewModule :: View -> Module -> M ()
viewModule Interface = viewModuleInterface
viewModule Documentation = viewModuleDocs

viewModuleInterface :: Module -> M ()
viewModuleInterface mod =
  viewInTerminalPaged
  . P.vsep
  . (mainHeading (mTitle mod) :)
  . numbered
  . map (prettyHtml . dSignature)
  . mDeclarations
  $ mod

viewModuleDocs :: Module -> M ()
viewModuleDocs (Module name minfo decls murl) =
  viewInTerminalPaged $ P.vsep $
    [ mainHeading name
    , Haddock.link $  P.text $ getUrl murl
    ]
    ++
    [ prettyHtml info | Just info <- [minfo] ]
    ++
    [""]
    ++
    [ prettyDecl decl | decl <- decls ]


viewPackage :: View -> Package -> M ()
viewPackage Interface = viewPackageInterface
viewPackage Documentation = viewPackageDocs

viewPackageInterface :: Package -> M ()
viewPackageInterface Package{..} =
  viewInTerminalPaged $ P.vsep $
    mainHeading pTitle : numbered (P.text <$> pModules)

viewPackageDocs :: Package -> M ()
viewPackageDocs Package{..} = viewInTerminalPaged $ P.vsep $
  [ mainHeading $ case pSubTitle of
      Nothing -> pTitle
      Just s -> pTitle <> ": " <> s
  , Haddock.link $  P.text $ getUrl pUrl
  , section "Description" (prettyHtml pDescription)
  ]
  ++
  [ section "Readme" $ prettyHtml readme | Just readme <- [pReadme] ]
  ++
  [ section "Properties" (P.vsep $ map viewProp pProperties) ]
  where
    section heading body =
      P.text heading <> P.nest 2 (P.linebreak <> body)

    viewProp (title, body) =
      section title (prettyHtml body)


viewInTerminal :: P.Doc -> M ()
viewInTerminal doc = do
  noColours <- State.gets sNoColours
  printDoc noColours stdout doc

viewInTerminalPaged :: P.Doc -> M ()
viewInTerminalPaged doc = do
  noColours <- State.gets sNoColours
  withPager $ \handle -> printDoc noColours handle doc

withPager :: MonadIO m => (Handle -> IO ())  -> m ()
withPager act = liftIO $
  MVar.newEmptyMVar >>= \mvar ->
  Async.withAsync (runPager mvar) $ \pager ->
  Async.withAsync (runAction mvar) $ \action -> do
    res <- Async.waitEitherCatch pager action
    case res of
      -- pager finished first. Action aborted.
      Left (Right _) -> return ()
      Left (Left err) -> throwIO err
      -- action finished first. Wait for pager.
      Right (Right _) -> void $ Async.waitCatch pager
      Right (Left err) -> throwIO err
  where
    cmd = (Process.proc "less" ["-iFRX"]) { Process.std_in = Process.CreatePipe }
    runAction mvar = do
      handle <- MVar.readMVar mvar
      act handle `finally` hClose handle

    runPager mvar =
      Process.withCreateProcess cmd
         $ \(Just hin) _ _ p -> do
           MVar.putMVar mvar hin
           Process.waitForProcess p

-- | Maximum screen width for flowing text.
-- Fixed-width portions will still overflow that.
maxWidth :: Int
maxWidth = 80

printDoc :: MonadIO m => Bool -> Handle -> P.Doc -> m ()
printDoc noColours handle doc = liftIO $ do
  width <- min maxWidth . maybe maxWidth Terminal.width <$> Terminal.size
  P.displayIO handle $ P.renderSmart 1 width $
    if noColours
       then P.plain doc
       else doc
  hPutStrLn handle ""

viewSource :: DeclUrl -> M ()
viewSource durl = do
  url <- sourceLink durl
  html <- fetchHTML url
  viewInEditor (fileInfo url html)
  where
    viewInEditor :: FileInfo -> M ()
    viewInEditor (FileInfo filename mline content) = do
      let line = maybe "" (("+" <>) . show) mline
      liftIO $ do
        editor <- getEditor
        withSystemTempFile filename $ \fullpath handle -> do
          Text.hPutStr handle content
          hFlush handle
          Process.callCommand $ unwords [editor, fullpath, line]

getEditor :: IO String
getEditor = getEnv "EDITOR" <|> getEnv "VISUAL" <|> defaultEditor
  where
    defaultEditor = error "no editor selected, make sure you have \
      \the 'EDITOR' environment variable defined for your shell"

moduleResult :: (String, Hoogle.Item -> Maybe Hoogle.Module)
moduleResult = ("module", toModule)
  where
    toModule = \case
      Hoogle.Module m -> Just m
      _               -> Nothing

declResult :: (String, Hoogle.Item -> Maybe Hoogle.Declaration)
declResult = ("declaration", toDecl)

toDecl :: Hoogle.Item -> Maybe Hoogle.Declaration
toDecl = \case
  Hoogle.Declaration d -> Just d
  _                    -> Nothing

-- ================================
-- Pretty printing
-- ================================

mainHeading :: String -> P.Doc
mainHeading str = P.vsep
  [ divider
  , P.indent 2 $ P.text str
  , divider
  ]
  where
    divider = P.text $ replicate maxWidth '='

viewDescription :: Hoogle.Item -> P.Doc
viewDescription = prettyHtml . Hoogle.description

viewSummary :: TargetGroup -> P.Doc
viewSummary tgroup = P.vsep
  [ viewDescription $ NonEmpty.head tgroup
  , viewPackageInfoList tgroup
  ]

viewPackageInfoList :: TargetGroup -> P.Doc
viewPackageInfoList
  = P.group
  . P.fillSep
  . P.punctuate P.comma
  . map viewItemPackageAndModule
  . toList

viewPackageName :: String -> P.Doc
viewPackageName = P.magenta . P.text

viewModuleName :: String -> P.Doc
viewModuleName = P.black . P.text

viewItemPackage :: Hoogle.Item -> P.Doc
viewItemPackage = \case
  Hoogle.Declaration d -> viewPackageName (Hoogle.dPackage d)
  Hoogle.Module m      -> viewPackageName (Hoogle.mPackage m)
  Hoogle.Package p     -> viewPackageName (Hoogle.pTitle p)

viewItemPackageAndModule :: Hoogle.Item -> P.Doc
viewItemPackageAndModule item = case item of
  Hoogle.Declaration d -> viewItemPackage item P.<+> viewModuleName (Hoogle.dModule d)
  Hoogle.Module _      -> viewItemPackage item
  Hoogle.Package _     -> viewItemPackage item

prettyDecl :: Declaration -> P.Doc
prettyDecl Declaration{..} =
  P.vsep $ map prettyHtml (dSignatureExpanded:dContent)

lookupDecl :: Anchor -> Module -> Maybe Declaration
lookupDecl anchor (Module _ _ decls _) =
  find (Set.member anchor . dAnchors) decls

viewTargetGroup :: TargetGroup -> M ()
viewTargetGroup tgroup = viewInTerminalPaged $ P.vsep
  [ divider
  , content
  , divider
  ]
  where
    divider = P.black $ P.text $ replicate 50 '='
    representative = NonEmpty.head tgroup
    toUrl = \case
      Hoogle.Declaration d -> getUrl $ Hoogle.dUrl d
      Hoogle.Module      m -> getUrl $ Hoogle.mUrl m
      Hoogle.Package     p -> getUrl $ Hoogle.pUrl p
    content = P.vsep $
      [ viewDescription representative
      , viewPackageInfoList tgroup
      , prettyHtml $ Hoogle.docs representative
      ] ++ reverse (Haddock.link . P.text . toUrl <$> toList tgroup)

-- ================================
-- Hoogle handling
-- ================================

-- | Get URL for source file for a target
sourceLink :: DeclUrl -> M SourceLink
sourceLink (DeclUrl murl anchor) = do
  html <- fetchHTML murl
  let links = sourceLinks murl html
  case lookup anchor links of
    Nothing -> throwError $ unlines $
      [ "anchor missing in module docs"
      , show murl
      ] ++ map show links
    Just slink -> return slink

declUrl :: Declaration -> DeclUrl
declUrl Declaration{..} =  DeclUrl dModuleUrl dAnchor

toPackageUrl :: ModuleUrl -> PackageUrl
toPackageUrl (ModuleUrl url) = PackageUrl $ fst $ breakOn "docs" url

packageModuleUrl :: PackageUrl -> String -> ModuleUrl
packageModuleUrl (PackageUrl purl) moduleName =
  ModuleUrl url
  where
    url =
      stripSuffix "/" purl
      ++ "/docs/"
      ++ map (replace '.' '-') moduleName
      ++ ".html"
    -- replace this with that
    replace this that x
      | x == this = that
      | otherwise = x

    stripSuffix x s = maybe s reverse $ stripPrefix x $ reverse s

-- =============================
--  HTTP requests
-- =============================

-- | Fetch and cache request's content
fetchHTML :: HasUrl a => a -> M HtmlPage
fetchHTML x = do
  src <- case location (getUrl x) of
    Local path -> liftIO $ LB.readFile path
    Remote url -> do
      req <- Http.parseRequest url
      fetch req
  return (parseHtmlDocument src)

data Location
  = Remote Url
  | Local FilePath

location :: Url -> Location
location url
  | any (`isPrefixOf` url) ["https://", "http://"] = Remote url
  | Just path <- stripPrefix "file://" url = Local path
  | otherwise = error $ "unable to parse URL protocol for: " <> url

fetch :: Http.Request -> M LB.ByteString
fetch req = do
  cache <- State.gets sCache
  cached cache (show req) $ do
      -- as http requests may take a while, tell the user what is happening.
      liftIO $ hPutStrLn stderr $ "fetching: " <> uriToString id (Http.getUri req) ""
      manager <- State.gets sManager
      eitherRes <- liftIO $ try $ Http.httpLbs req manager
      res <- either (throwError . prettyHttpError) return eitherRes
      let status = Http.responseStatus res
      unless (Http.statusIsSuccessful status) $
        throwError
          $ "unable to fetch page: "
          <> Text.unpack (Text.decodeUtf8 $ Http.statusMessage status)
      return $ Http.responseBody res

  where
    prettyHttpError :: Http.HttpException -> String
    prettyHttpError httpErr = "*** HTTP Error: " <> case httpErr of
      Http.InvalidUrlException _ msg ->
         "invalid URL: " <> msg
      Http.HttpExceptionRequest _ err -> case err of
        Http.StatusCodeException res _ ->
          "invalid response status: " <> show (Http.responseStatus res)
        Http.TooManyRedirects _ -> "too many redirects"
        Http.OverlongHeaders -> "overlong headers"
        Http.ResponseTimeout -> "response timeout"
        Http.ConnectionTimeout -> "connection timeout"
        Http.ConnectionFailure _ ->
          "connection failure. Check your internet connection"
        Http.InvalidStatusLine _ -> "invalid status line"
        Http.InvalidHeader _ -> "invalid header"
        Http.InvalidRequestHeader _ -> "invalid request header"
        Http.InternalException e -> "internal exception: " <> show e
        Http.ProxyConnectException _ _ status ->
          "unable to connect to proxy: " <> show status
        Http.NoResponseDataReceived -> "no response data received"
        Http.TlsNotSupported -> "tls not supported"
        Http.WrongRequestBodyStreamSize _ _ -> "wrong request stream size"
        Http.ResponseBodyTooShort _ _ -> "reponse body too short"
        Http.InvalidChunkHeaders -> "invalid chunk headers"
        Http.IncompleteHeaders -> "incomplete headers"
        Http.InvalidDestinationHost _ -> "invalid destination host"
        Http.HttpZlibException e -> "zlib exception: " <> show e
        Http.InvalidProxyEnvironmentVariable var val ->
          "invalid proxy environment var: " <> show var <> ": " <> show val
        Http.ConnectionClosed -> "connection closed"
        Http.InvalidProxySettings _ -> "invalid proxy settings"