packages feed

stan-0.1.0.0: src/Stan/Observation.hs

{- |
Copyright: (c) 2020 Kowainik
SPDX-License-Identifier: MPL-2.0
Maintainer: Kowainik <xrom.xkov@gmail.com>

__Observation__ — a suggestion found in the target project by @Stan@.
-}

module Stan.Observation
    ( Observation (..)
    , Observations

      -- * Smart constructors
    , mkObservation
    , mkObservationId

    , ignoredObservations

      -- * Pretty print
    , prettyShowObservation
    , prettyShowIgnoredObservations
    , prettyObservationSource
    ) where

import Colourista (blue, bold, formatWith, green, italic, reset, yellow)
import Colourista.Short (b, i)
import Data.Aeson.Micro (ToJSON (..), object, (.=))
import Data.List (partition)
import Slist (Slist)

import Stan.Category (prettyShowCategory)
import Stan.Core.Id (Id (..))
import Stan.Core.ModuleName (ModuleName (..), fromGhcModule)
import Stan.Ghc.Compat (RealSrcSpan, srcSpanEndCol, srcSpanEndLine, srcSpanFile, srcSpanStartCol,
                        srcSpanStartLine)
import Stan.Hie.Compat (HieFile (..))
import Stan.Inspection (Inspection (..))
import Stan.Inspection.All (getInspectionById)
import Stan.Report.Settings (OutputSettings (..), Verbosity (..), isHidden)
import Stan.Severity (prettyShowSeverity, severityColour)

import qualified Crypto.Hash.SHA1 as SHA1
import qualified Data.ByteString.Base64 as Base64
import qualified Data.ByteString.Char8 as BS
import qualified Data.HashSet as HS
import qualified Data.List.NonEmpty as NE
import qualified Data.Text as Text
import qualified Slist as S


{- | Data type to represent discovered by Stan suggestions.
-}
data Observation = Observation
    { observationId           :: !(Id Observation)
    , observationInspectionId :: !(Id Inspection)
    , observationSrcSpan      :: !RealSrcSpan
    , observationFile         :: !FilePath
    , observationModuleName   :: !ModuleName
    , observationFileContent  :: !ByteString
    } deriving stock (Show, Eq)

instance ToJSON Observation where
    toJSON Observation{..} = object
        [ "id"           .= observationId
        , "inspectionId" .= observationInspectionId
        , "srcSpan"      .= showSpan observationSrcSpan
        , "startLine"    .= srcSpanStartLine observationSrcSpan
        , "startCol"     .= srcSpanStartCol observationSrcSpan
        , "endLine"      .= srcSpanEndLine observationSrcSpan
        , "endCol"       .= srcSpanEndCol observationSrcSpan
        , "file"         .= toText observationFile
        , "moduleName"   .= observationModuleName
        ]

-- | Type alias for the sized list of 'Observation's.
type Observations = Slist Observation

-- | Smart constructor for 'Observation's from 'HieFile's.
mkObservation
    :: Id Inspection  -- ^ Corresponding 'Inspection's 'Id'.
    -> HieFile
    -> RealSrcSpan  -- ^ Position.
    -> Observation
mkObservation insId HieFile{..} srcSpan = Observation
    { observationId = mkObservationId insId moduleName srcSpan
    , observationInspectionId = insId
    , observationSrcSpan = srcSpan
    , observationFile = hie_hs_file
    , observationModuleName = moduleName
    , observationFileContent = hie_hs_src
    }
  where
    moduleName :: ModuleName
    moduleName = fromGhcModule hie_module


-- | Show 'Observation' in a human-friendly format.
prettyShowObservation :: OutputSettings -> Observation -> Text
prettyShowObservation OutputSettings{..} o@Observation{..} = case outputSettingsVerbosity of
    NonVerbose -> simpleShowObservation
    Verbose -> unlines $ map (" ┃  " <>)
        $  observationTable
        <> ("" : prettyObservationSource True o)
        <> ("" : solution)
  where
    simpleShowObservation :: Text
    simpleShowObservation =
        " ✦ "
        <> b (unId observationId)
        <>" [" <> sev <> "] "
        <> i (showSpan observationSrcSpan)
        <> " — "
        <> inspectionName inspection


    observationTable :: [Text]
    observationTable =
        [ element "ID:            " <> b (unId observationId)
        , element "Severity:      " <> sev
        , element "Inspection ID: " <> unId observationInspectionId
        , element "Name:          " <> inspectionName inspection
        , element "Description:   " <> inspectionDescription inspection
        , element "Category:      " <> categories
        , element "File:          " <> toText observationFile
        ]
      where
        element :: Text -> Text
        element = formatWith [italic] . ("✦ " <>)

    sev :: Text
    sev = prettyShowSeverity (inspectionSeverity inspection)

    inspection :: Inspection
    inspection = getInspectionById observationInspectionId

    categories :: Text
    categories = Text.intercalate " "
        $ map prettyShowCategory $ NE.toList $ inspectionCategory inspection

    solution :: [Text]
    solution
        | isHidden outputSettingsSolutionVerbosity || null sols = []
        | otherwise = "💡 " <> formatWith [italic, green] "Possible solution:" :
            map ("    ⍟ " <>) sols
      where
        sols :: [Text]
        sols = inspectionSolution inspection


prettyObservationSource
    :: Bool  -- ^ Use colouring
    -> Observation
    -> [Text]
prettyObservationSource isColour Observation{..} =
      alignLine (n - 1)
    : map (\x -> alignLine x <> getSourceLine x) [n .. endL]
    ++ [alignLine (endL + 1) <> arrows]
  where
    n, endL :: Int
    n = srcSpanStartLine observationSrcSpan
    endL = srcSpanEndLine observationSrcSpan

    alignLine :: Int -> Text
    alignLine x = Text.justifyRight 4 ' ' (show x) <> " ┃ "

    getSourceLine :: Int -> Text
    getSourceLine x = maybe
        "<UNAVAILABLE> Open the issue in the tool that created the HIE files for you."
        decodeUtf8
        (BS.lines observationFileContent !!? (x - 1))

    arrows :: Text
    arrows = whenColour isColour (severityColour $ inspectionSeverity $ getInspectionById observationInspectionId)
        <> Text.replicate start " "
        <> Text.replicate arrow "^"
        <> whenColour isColour reset
      where
        start = srcSpanStartCol observationSrcSpan - 1
        arrow = srcSpanEndCol observationSrcSpan - start - 1

{- | Show 'RealSrcSpan' in the following format:

@
filename.ext(11:12-13:14)
@
-}
showSpan :: RealSrcSpan -> Text
showSpan s = show (srcSpanFile s)
    <> "(" <> show (srcSpanStartLine s)
    <> ":" <> show (srcSpanStartCol s)
    <> "-" <> show (srcSpanEndLine s)
    <> ":" <> show (srcSpanEndCol s)
    <> ")"

{- | Checkes the predicate on colourfulness and returns an empty text when the
colouroing is disabled.
-}
whenColour :: Bool -> Text -> Text
whenColour = memptyIfFalse

{- Returns the list of ignored and unrecognised 'Observation' 'Id's
respectfully.
-}
ignoredObservations
    :: [Id Observation]
    -> Observations
    -> ([Id Observation], [Id Observation])
      -- ^ Ignored         ^ Unknown
ignoredObservations ids obs = (ignoredIds, unknownIds)
  where
    obsIds :: HashSet (Id Observation)
    obsIds = fromList $ toList $ S.map observationId obs

    ignoredIds, unknownIds :: [Id Observation]
    (ignoredIds, unknownIds) = partition (`HS.member` obsIds) ids

{- Pretty shows the list of ignored and unrecognised 'Observation' 'Id's
respectfully.

@
Ignored Observation IDs:
    - OBS-STAN-0005-ZKmeC0-125:45
Unrecognised Observation IDs:
    - asd
@
-}
prettyShowIgnoredObservations :: [Id Observation] -> Observations -> Text
prettyShowIgnoredObservations [] _ = ""
prettyShowIgnoredObservations ids obs = ignored <> unknown
  where
    ignored :: Text
    ignored =
        if null ignoredIds
        then ""
        else formatWith [bold, blue] "Ignored Observation IDs:\n"
            <> showIds ignoredIds

    unknown :: Text
    unknown =
        if null unknownIds
        then ""
        else formatWith [bold, yellow] "Unrecognised Observation IDs:\n"
            <> showIds unknownIds

    showIds :: [Id Observation] -> Text
    showIds = unlines . map ((<>) "    - " . unId)

    ignoredIds, unknownIds :: [Id Observation]
    (ignoredIds, unknownIds) = ignoredObservations ids obs

{- | Create a stable 'Observation' 'Id' in a such way that:

1. 'Id' doesn't depend on other inspections in this file.
2. 'Id' uniquely identifies 'Observation' location.
3. 'Id's are guaranteed to be the same if the module content didn't
change between different @stan@ runs.

The 'Observation' 'Id' should look like this:

@
OBS-STAN-XXXX-<module-name-hash>-10:42
@
-}
mkObservationId :: Id Inspection -> ModuleName -> RealSrcSpan -> Id Observation
mkObservationId insId moduleName srcSpan = Id $ Text.intercalate "-"
    [ "OBS"
    , unId insId
    , hashModuleName moduleName
    , show (srcSpanStartLine srcSpan) <> ":" <> show (srcSpanStartCol srcSpan)
    ]

{- | Hash module name to a short string of length @6@. Hashing
algorithm is the following:

1. First, run SHA-1.
2. Then, encode with @base64@.
3. Last, take first @6@ characters.
-}
hashModuleName :: ModuleName -> Text
hashModuleName =
    Text.take 6
    . Base64.encodeBase64
    . SHA1.hash
    . encodeUtf8
    . unModuleName