packages feed

ribosome-host-0.9.9.9: lib/Ribosome/Host/Data/Report.hs

-- |Data structures related to logging and notifying the user
module Ribosome.Host.Data.Report where

import qualified Data.Text as Text
import Exon (exon)
import Fcf (Pure1, type (@@))
import Fcf.Class.Functor (FMap)
import Polysemy.Log (Severity (Error))
import Prelude hiding (tag)
import Text.Show (showParen, showsPrec)

-- |The provenance of a report, for use in logs.
newtype ReportContext =
  ReportContext { unReportContext :: [Text] }
  deriving stock (Eq, Show)
  deriving newtype (Ord, Semigroup, Monoid)

-- |Render a 'ReportContext' by interspersing it with dots, returning 'Nothing' if it is empty.
reportContext' :: ReportContext -> Maybe Text
reportContext' = \case
  ReportContext [] -> Nothing
  ReportContext c -> Just (Text.intercalate "." c)

-- |Render a 'ReportContext' by interspersing it with dots, followed by a colon, returning 'Nothing' if it is empty.
prefixReportContext' :: ReportContext -> Maybe Text
prefixReportContext' c =
  flip Text.snoc ':' <$> reportContext' c

-- |Render a 'ReportContext' by interspersing it with dots, using @global@ if it is empty.
reportContext :: ReportContext -> Text
reportContext c =
  fromMaybe "global" (reportContext' c)

-- |Render a 'ReportContext' by interspersing it with dots, followed by a colon, using @global@ if it is empty.
prefixReportContext :: ReportContext -> Text
prefixReportContext c =
  Text.snoc (reportContext c) ':'

instance IsString ReportContext where
  fromString =
    ReportContext . pure . toText

-- |An report with different messages intended to be sent to Neovim and the log, respectively.
--
-- Used by request handlers and expected by the RPC dispatcher.
--
-- Also contains the 'Severity' of the report, or minimum log level, which determines whether the report should be
-- logged and echoed in Neovim, and what kind of highlighting should be used in Neovim (red for errors, orange for
-- warnings, none for infomrational errors).
--
-- The log message may span multiple lines.
data Report where
  Report :: HasCallStack => {
    user :: Text,
    log :: [Text],
    severity :: Severity
  } -> Report

instance Show Report where
  showsPrec d Report {..} =
    showParen (d > 10)
    [exon|LogReport { user = #{showsPrec 11 user}, log = #{showsPrec 11 log}, severity = #{showsPrec 11 severity} }|]

instance IsString Report where
  fromString (toText -> s) =
    Report s [s] Error

-- |The type used by request handlers and expected by the RPC dispatcher.
data LogReport =
  LogReport {
    -- |The report
    report :: Report,
    -- |Indicates whether this report may be echoed in Neovim
    echo :: Bool,
    -- |Indicates whether to store this report in the state of 'Ribosome.Reports'
    store :: Bool,
    -- |A list of prefixes used for log messages
    context :: ReportContext
  }
  deriving stock (Show, Generic)

-- |Construct a 'LogReport' error from a single 'Text'.
simple ::
  HasCallStack =>
  Text ->
  LogReport
simple msg =
  withFrozenCallStack do
    LogReport (Report msg [msg] Error) True True mempty

-- |Stop with a 'LogReport'.
basicReport ::
  Member (Stop Report) r =>
  HasCallStack =>
  Text ->
  [Text] ->
  Sem r a
basicReport user log =
  withFrozenCallStack do
    stop (Report user log Error)

instance IsString LogReport where
  fromString :: HasCallStack => String -> LogReport
  fromString (toText -> msg) =
    withFrozenCallStack do
      LogReport (Report msg [msg] Error) True True mempty

-- |The class of types that are convertible to a 'Report'.
--
-- This is used to create a uniform format for handlers, since control flow is passed on to the internal machinery when
-- they return.
-- If an error would be thrown that is not caught by the request dispatcher, the entire plugin would stop, so all 'Stop'
-- and 'Resumable' effects need to be converted to 'Report' before returning (see [Errors]("Ribosome#errors")).
--
-- The combinators associated with this class make this task a little less arduous:
--
-- > data NumbersError = InvalidNumber
-- >
-- > instance Reportable NumbersError where
-- >   toReport InvalidNumber = Report "Invalid number!" ["The user entered an invalid number"] Warn
-- >
-- > count :: Int -> Sem r Int
-- > count i =
-- >   resumeReport @Rpc $ mapReport @NumbersError do
-- >     when (i == 0) (stop InvalidNumber)
-- >     nvimGetVar ("number_" <> show i)
--
-- Here 'resumeReport' converts a potential 'RpcError' from 'Ribosome.Api.nvimGetVar' to 'Report' (e.g. if the variable
-- is not set), while 'mapReport' uses the instance @'Reportable' 'NumbersError'@ to convert the call to 'stop'.
class Reportable e where
  toReport :: e -> Report

instance Reportable Report where
  toReport =
    id

instance Reportable Void where
  toReport = \case

-- |Reinterpret @'Stop' err@ to @'Stop' 'Report'@ if @err@ is an instance of 'Reportable'.
mapReport ::
  ∀ e r a .
  Reportable e =>
  Member (Stop Report) r =>
  Sem (Stop e : r) a ->
  Sem r a
mapReport =
  mapStop toReport

type Stops errs =
  FMap (Pure1 Stop) Fcf.@@ errs

-- |Map multiple errors to 'Report'.
class MapReports (errs :: [Type]) (r :: EffectRow) where
  -- |Map multiple errors to 'Report'.
  -- This needs the errors specified as type applications.
  --
  -- > mapReports @[RpcError, SettingError]
  mapReports :: InterpretersFor (Stops errs) r

instance MapReports '[] r where
  mapReports =
    id

instance (
    Reportable err,
    MapReports errs r,
    Member (Stop Report) (Stops errs ++ r)
  ) => MapReports (err : errs) r where
    mapReports =
      mapReports @errs . mapReport @err

-- |Convert the effect @eff@ to @'Resumable' err eff@ and @'Stop' 'Report'@ if @err@ is an instance of 'Reportable'.
resumeReport ::
  ∀ eff e r a .
  Reportable e =>
  Members [eff !! e, Stop Report] r =>
  Sem (eff : r) a ->
  Sem r a
resumeReport =
  resumeHoist toReport

-- |Resume multiple effects as 'Report's.
class ResumeReports (effs :: EffectRow) (errs :: [Type]) (r :: EffectRow) where
  -- |Resume multiple effects as 'Report's.
  -- This needs both effects and errors specified as type applications (though only the shape for the errors).
  --
  -- > resumeReports @[Rpc, Settings] @[_, _]
  resumeReports :: InterpretersFor effs r

instance ResumeReports '[] '[] r where
  resumeReports =
    id

instance (
    Reportable err,
    ResumeReports effs errs r,
    Members [eff !! err, Stop Report] (effs ++ r)
  ) => ResumeReports (eff : effs) (err : errs) r where
    resumeReports =
      resumeReports @effs @errs . resumeReport @eff @err

-- |Extract both user and log messages from an 'Report', for use in tests.
reportMessages :: Report -> Text
reportMessages Report {user, log} =
  unlines (user : log)

-- |Extract the user message from an instance of 'Reportable'.
userReport ::
  ∀ e .
  Reportable e =>
  e ->
  Text
userReport (toReport -> Report {user}) =
  user

-- |Resume an effect with an error that's an instance of 'Reportable' by passing its user message to a function.
resumeHoistUserMessage ::
  ∀ err eff err' r .
  Reportable err =>
  Members [eff !! err, Stop err'] r =>
  (Text -> err') ->
  InterpreterFor eff r
resumeHoistUserMessage f =
  resumeHoist (f . userReport)

-- |Map an error that's an instance of 'Reportable' by passing its user message to a function.
mapUserMessage ::
  ∀ err err' r .
  Reportable err =>
  Member (Stop err') r =>
  (Text -> err') ->
  InterpreterFor (Stop err) r
mapUserMessage f =
  mapStop (f . userReport)

-- |Convert an error that's an instance of 'Reportable' to 'Fail', for use in tests.
stopReportToFail ::
  ∀ e r .
  Member Fail r =>
  Reportable e =>
  InterpreterFor (Stop e) r
stopReportToFail =
  either (fail . toString . userReport) pure <=< runStop
{-# inline stopReportToFail #-}

-- |Resume an effect with an error that's an instance of 'Reportable' by reinterpreting to 'Fail', for use in tests.
resumeReportFail ::
  ∀ eff err r .
  Members [Fail, eff !! err] r =>
  Reportable err =>
  InterpreterFor eff r
resumeReportFail =
  resuming (fail . toString . userReport)
{-# inline resumeReportFail #-}