packages feed

tadka-2.0.0.0: src/Tadka/Internal/Renderer/Narratable.hs

{-# LANGUAGE OverloadedStrings #-}

-- | Narratable (accessibility-first) report handler:
-- prose, not layout. Every field the graphical handler can show has a prose
-- equivalent here — nothing is silently dropped between renderers.
--
-- 'Ann' is interpreted for prose via 'toProseMarker' (e.g. inline code is
-- surrounded with quotes), so annotated message\/label\/help content reads
-- naturally aloud.
--
-- No compatibility guarantee.
module Tadka.Internal.Renderer.Narratable
  ( NarratableOptions (..)
  , renderNarratable
  , toProseMarker
  ) where

import qualified Data.List.NonEmpty                    as NE
import           Data.Text                             (Text)
import           Data.Char                             (isControl)
import qualified Data.Text                             as T
import           Numeric.Natural                       (Natural)
import           Prettyprinter                         (Doc, LayoutOptions (..),
                                                        PageWidth (Unbounded),
                                                        layoutPretty)
import           Prettyprinter.Render.Util.SimpleDocTree (renderSimplyDecorated, treeForm)

import           Tadka.Internal.Ann                    (Ann (..))
import           Tadka.Internal.Context                (Context, LabelKind (..),
                                                        LabelState (..), Labeled (..),
                                                        SourceGroup (..), contextSourceGroups)
import           Tadka.Internal.SourceCode             (SourceCode (..))
import           Tadka.Internal.Diagnostic             (Diagnostic (..), SomeDiagnostic (..))
import           Tadka.Internal.Related                (RelatedTree (..),
                                                        TerminationReason (..), walkCauses,
                                                        walkRelated)
import           Tadka.Internal.Span                   (LineCol (..),
                                                        StaleReason (..), resolvedStart,
                                                        spanLength)
import           Tadka.Internal.Types                  (Severity, SeverityLabels (..),
                                                        severityLabels, unDiagnosticCode,
                                                        unLength, unUrl)

-- | Resolved settings for the narratable handler (populated only by
-- @selectRenderer@).
newtype NarratableOptions = NarratableOptions
  { noRelatedDepth :: Natural }
  deriving (Eq, Show)

-- | How an 'Ann' is marked in prose. Inline code and file names are surrounded
-- with quotes so they read as distinct tokens; emphasis and keywords carry no
-- prose marker.
toProseMarker :: Ann -> Text
toProseMarker AnnCode     = "\""
toProseMarker AnnFilename = "\""
toProseMarker AnnEmphasis = ""
toProseMarker AnnKeyword  = ""

-- | Render an annotated document to prose, wrapping each annotated span with
-- its 'toProseMarker'.
docToProse :: Doc Ann -> Text
docToProse =
    T.map flat . renderSimplyDecorated id wrap . treeForm . layoutPretty (LayoutOptions Unbounded)
  where wrap ann inner = toProseMarker ann <> inner <> toProseMarker ann
        flat c = if isControl c then ' ' else c   -- one line per sentence; strip control chars

renderNarratable :: Diagnostic e => NarratableOptions -> e -> Text
renderNarratable opts e = T.intercalate "\n" (renderProse opts (SomeDiagnostic e))

renderProse :: NarratableOptions -> SomeDiagnostic -> [Text]
renderProse opts sd@(SomeDiagnostic e) =
     headerSentence (severity e) (fmap unDiagnosticCode (code e)) (docToProse (message e))
   : contextSentences (context e)
  ++ helpSentences (fmap docToProse (help e)) (fmap unUrl (url e))
  ++ causeSentences (walkCauses (noRelatedDepth opts) sd)
  ++ relatedSentences opts (walkRelated (noRelatedDepth opts) sd)

-- === Header ===============================================================

headerSentence :: Severity -> Maybe Text -> Text -> Text
headerSentence sev mcode msg = prefix <> " " <> codeClause <> msg <> "."
  where
    prefix     = severityNarratablePrefix (severityLabels sev)     -- e.g. "Error,"
    codeClause = maybe "" (\c -> "code " <> c <> ": ") mcode

-- === Context (location, source, labels) ===================================

-- | One prose block per source group, in group order (Phase 12: a context can
-- now span more than one source; each group gets its own \"Location: ...\"
-- sentence anchored on that group's own first primary label, exactly as the
-- single-group case always has). A one-group context — the only shape v1 ever
-- produced before Phase 12 — yields exactly the same sentences, in the same
-- order, as before: this is a strict generalisation of the prior behaviour,
-- not a different rendering for the case that already worked.
contextSentences :: Context -> [Text]
contextSentences = concatMap groupSentences . contextSourceGroups

groupSentences :: SourceGroup -> [Text]
groupSentences (SourceGroup src labels) = locationSentence ++ labelReadouts
  where
    indexed = NE.toList labels
    oks     = [ (k, rs) | Labeled (LabelOk rs) k _ <- indexed ]

    locRs = case [ rs | (Primary, rs) <- oks ] of
      (rs:_) -> Just rs
      []     -> case oks of ((_, rs):_) -> Just rs; _ -> Nothing
    locationSentence = case locRs of
      Just rs -> [ "Location: " <> scName src
                     <> ", line "   <> tshow (lcLine (resolvedStart rs))
                     <> ", column " <> tshow (lcColumn (resolvedStart rs)) <> "." ]
      Nothing -> [ "Location: " <> scName src <> "." ]

    labelReadouts = concatMap readout indexed
    readout (Labeled (LabelOk rs) k txt) =
      [ "Source line " <> tshow n <> ": \"" <> lineTextAt src n <> "\"."
      , leadIn k <> colClause <> labeled ]
      where
        n       = lcLine (resolvedStart rs)
        startC  = lcColumn (resolvedStart rs)
        effLen  = max 1 (unLength (spanLength rs))
        endC    = startC + effLen - 1
        colClause | startC == endC = "column " <> tshow startC
                  | otherwise      = "columns " <> tshow startC <> " through " <> tshow endC
        labeled = maybe "." (\t -> ", labeled: " <> docToProse t <> ".") txt
    readout (Labeled (LabelStale reason) _ txt) = [ staleSentence reason txt ]

    leadIn Primary   = "The problem is at "
    leadIn Secondary = "Related context is at "

staleSentence :: StaleReason -> Maybe (Doc Ann) -> Text
staleSentence reason txt =
  "A labeled position could not be shown because " <> reasonText reason
    <> maybe "." (\t -> ", labeled: " <> docToProse t <> ".") txt
  where
    reasonText SpanOutOfBounds = "the span is out of bounds for the current source"

-- === Help / URL ===========================================================

helpSentences :: Maybe Text -> Maybe Text -> [Text]
helpSentences mhelp murl =
     [ "Help: " <> h             | Just h <- [mhelp] ]
  ++ [ "More information: " <> u | Just u <- [murl] ]

-- === Related ==============================================================

relatedSentences :: NarratableOptions -> RelatedTree -> [Text]
relatedSentences opts (RelatedTree rootDiag children term) =
     concatMap (relatedChild opts) children
  ++ truncationNote term (numRelated rootDiag)

relatedChild :: NarratableOptions -> RelatedTree -> [Text]
relatedChild opts (RelatedTree childDiag kids term) = case term of
  CycleOmitted -> [ "A related diagnostic was omitted because it forms a cycle." ]
  _ ->
       ("Related: " <> summaryProse childDiag <> ".")
       -- Same call, same shape, as 'renderProse': a related diagnostic's own
       -- cause chain reads exactly as the root's would, just nested here.
     : causeSentences (walkCauses (noRelatedDepth opts) childDiag)
    ++ concatMap (relatedChild opts) kids
    ++ truncationNote term (numRelated childDiag)

truncationNote :: TerminationReason -> Int -> [Text]
truncationNote DepthTruncated n =
  [ tshow n <> " more related diagnostic" <> plural n <> " omitted at the depth limit." ]
truncationNote _ _ = []

plural :: Int -> Text
plural 1 = " was"
plural _ = "s were"

causeSentences :: [SomeDiagnostic] -> [Text]
causeSentences = map (\c -> "Caused by: " <> summaryProse c <> ".")

summaryProse :: SomeDiagnostic -> Text
summaryProse (SomeDiagnostic e) = case code e of
  Just c  -> unDiagnosticCode c <> " \x2014 " <> msg    -- em dash
  Nothing -> msg
  where msg = docToProse (message e)

numRelated :: SomeDiagnostic -> Int
numRelated (SomeDiagnostic e) = length (related e)

-- === Helpers ==============================================================

-- | Text of source line @n@ (1-based), fetched as a one-line window. Total.
lineTextAt :: SourceCode a => a -> Int -> Text
lineTextAt src n = case scLines src (n, n) of
  ((_, t) : _) -> T.map (\c -> if isControl c then ' ' else c) t
  []           -> ""

tshow :: Show a => a -> Text
tshow = T.pack . show