packages feed

hasql-check-0.5.0.0: src/Hasql/Check/Finding.hs

-- |
-- Module      : Hasql.Check.Finding
-- Copyright   : (c) 2026 Kronor.io
--
-- License     : BSD-3-Clause
-- Maintainer  : lorenzo@kronor.io
-- Stability   : experimental
-- Portability : GHC
--
-- Warnings about a statement, and the two ways of suppressing them.
--
-- A directive, in the comment lines directly above the line a quote starts on,
-- suppresses findings about that quote's statement:
--
-- > -- hasql-check: allow nullable(street, city), narrowing($4)
-- > [singletonStatement| ... |]
--
-- A baseline file lists findings to ignore wherever their statement is, so
-- that existing findings can be left while new ones fail CI.
module Hasql.Check.Finding (
    -- * Findings
    Rule (..),
    ruleName,
    Finding (..),

    -- * Directives
    Allowance,
    directivesAbove,
    applyDirectives,

    -- * Baselines
    Baseline,
    BaselineEntry (..),
    statementHash,
    readBaseline,
    renderBaseline,
    inBaseline,

    -- * Both
    settle,
) where

import Data.Bits (xor)
import Data.ByteString (ByteString)
import Data.ByteString qualified as ByteString
import Data.Char (isSpace)
import Data.List (partition, sortOn)
import Data.Set (Set)
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Word (Word64)
import Numeric (showHex)
import Prelude


-- * Findings


data Rule
    = -- | A result column annotated non-null that can be null.
      Nullable
    | -- | An annotation that converts a value to a narrower type.
      Narrowing
    deriving stock (Eq, Ord, Show, Enum, Bounded)


ruleName :: Rule -> Text
ruleName = \case
    Nullable -> "nullable"
    Narrowing -> "narrowing"


data Finding = Finding
    { rule :: Rule
    , subject :: Text
    -- ^ The result column's name, or the parameter as @$n@.
    , message :: String
    }


-- * Directives


-- | A rule allowed for a subject.
type Allowance = (Rule, Text)


-- | The allowances in the block of comment lines directly above a line, given
-- the file's lines and the line, from 1. Malformed directives are reported.
directivesAbove :: [Text] -> Int -> ([Allowance], [String])
directivesAbove fileLines line =
    foldMap parseLine (takeWhile isComment (reverse (take (line - 1) fileLines)))
  where
    isComment = Text.isPrefixOf "--" . Text.stripStart
    parseLine l = case Text.breakOn "hasql-check:" l of
        (_, rest) | not (Text.null rest) -> parseDirective (Text.strip (Text.drop (Text.length "hasql-check:") rest))
        _ -> mempty


parseDirective :: Text -> ([Allowance], [String])
parseDirective t = case Text.stripPrefix "allow" t of
    Just rest -> foldMap entry (splitTopLevel (Text.strip rest))
    Nothing -> ([], ["directive not understood: " <> Text.unpack t])
  where
    entry e = case Text.breakOn "(" (Text.strip e) of
        (name, args)
            | Just inner <- Text.stripPrefix "(" args >>= Text.stripSuffix ")"
            , Just r <- lookup (Text.strip name) [(ruleName r', r') | r' <- [minBound .. maxBound]] ->
                case subjects inner of
                    Right names -> ([(r, subj) | subj <- names], [])
                    Left err -> ([], ["directive not understood: " <> Text.unpack e <> ": " <> err])
        _ -> ([], ["directive not understood: " <> Text.unpack e])
    -- Split on the commas between entries, not those inside them.
    splitTopLevel = go 0 ""
      where
        go :: Int -> Text -> Text -> [Text]
        go depth acc s = case Text.uncons s of
            Nothing -> [acc | not (Text.all isSpace acc)]
            Just (c, cs)
                | c == ',' && depth == 0 -> acc : go depth "" cs
                | c == '(' -> go (depth + 1) (Text.snoc acc c) cs
                | c == ')' -> go (depth - 1) (Text.snoc acc c) cs
                | otherwise -> go depth (Text.snoc acc c) cs


-- | The findings the allowances leave, and the allowances that suppressed
-- nothing for the rules that were checked.
applyDirectives :: [Rule] -> [Allowance] -> [Finding] -> ([Finding], [Allowance])
applyDirectives checked allowances findings =
    (kept, [a | a@(r, _) <- allowances, r `elem` checked, a `Set.notMember` used])
  where
    allowed f = (f.rule, f.subject) `elem` allowances
    (suppressed, kept) = partition allowed findings
    used = Set.fromList [(f.rule, f.subject) | f <- suppressed]


-- * Baselines


data BaselineEntry = BaselineEntry
    { hash :: Text
    , entryRule :: Rule
    , entrySubject :: Text
    , origin :: Text
    -- ^ Where the statement was when the entry was written, for people.
    }


-- | Findings to ignore, by statement, rule and subject.
newtype Baseline = Baseline (Set (Text, Rule, Text))


-- | A stable hash of a statement as rendered, so that layout changes to the
-- quote leave it alone. 64-bit FNV-1a.
statementHash :: ByteString -> Text
statementHash = Text.pack . pad . flip showHex "" . ByteString.foldl' step offset
  where
    offset = 0xcbf29ce484222325 :: Word64
    step h b = (h `xor` fromIntegral b) * 0x100000001b3
    pad s = replicate (16 - length s) '0' <> s


-- | One entry a line: hash, rule, subject, then optionally @# origin@.
readBaseline :: Text -> Either String Baseline
readBaseline = fmap (Baseline . Set.fromList) . traverse entry . filter (not . blank) . Text.lines
  where
    blank l = Text.null (Text.strip l) || "#" `Text.isPrefixOf` Text.stripStart l
    entry l = either (\err -> Left (concat ["Malformed baseline line: ", Text.unpack l, ": ", err])) Right do
        let (h, afterHash) = Text.break isSpace (Text.stripStart l)
            (r, afterRule) = Text.break isSpace (Text.stripStart afterHash)
        r' <- maybe (Left "unknown rule") Right (lookup r [(ruleName x, x) | x <- [minBound .. maxBound]])
        (subj, rest) <- word (Text.stripStart afterRule)
        if Text.null (Text.strip rest) || "#" `Text.isPrefixOf` Text.stripStart rest
            then Right (h, r', subj)
            else Left "trailing text"


renderBaseline :: [BaselineEntry] -> Text
renderBaseline entries =
    Text.unlines
        ( "# hasql-check baseline: findings to ignore. Written by --write-baseline."
            : [ Text.unwords [e.hash, ruleName e.entryRule, quote e.entrySubject, "#", e.origin]
              | e <- sortOn (\e -> (e.origin, e.hash, e.entryRule, e.entrySubject)) entries
              ]
        )


inBaseline :: Baseline -> Text -> Finding -> Bool
inBaseline (Baseline entries) h f = (h, f.rule, f.subject) `Set.member` entries


-- * Both


-- | Apply a quote's directives and the baseline to its statement's findings:
-- the findings left after the directives, which are what a baseline written
-- now would hold, and the messages to report - the findings the baseline does
-- not cover either, malformed directives, and allowances that suppress
-- nothing.
settle :: [Rule] -> ([Allowance], [String]) -> Maybe Baseline -> Text -> [Finding] -> ([Finding], [String])
settle checked (allowances, malformed) baseline h findings =
    ( kept
    , [f.message | f <- kept, maybe True (\b -> not (inBaseline b h f)) baseline]
        <> malformed
        <> [ concat ["allow ", Text.unpack (ruleName r), "(", Text.unpack subj, ") suppresses nothing"]
           | (r, subj) <- unused
           ]
    )
  where
    (kept, unused) = applyDirectives checked allowances findings


-- * Names


-- | A column name as written in a baseline or a directive: bare, or in double
-- quotes when it has spaces or punctuation, with @\\@ and @\"@ escapes.
quote :: Text -> Text
quote name
    | not (Text.null name) && Text.all plain name = name
    | otherwise = Text.concat ["\"", Text.concatMap escape name, "\""]
  where
    plain c = not (isSpace c) && c `notElem` ("#\"\\,()" :: String)
    escape c = if c == '"' || c == '\\' then Text.pack ['\\', c] else Text.singleton c


-- | A name, bare or quoted, at the start of the text, and the text after it.
word :: Text -> Either String (Text, Text)
word t = case Text.uncons t of
    Just ('"', rest) -> go "" rest
    _ ->
        let (bare, rest) = Text.break (\c -> isSpace c || c == ',') t
         in if Text.null bare then Left "missing name" else Right (bare, rest)
  where
    go acc s = case Text.uncons s of
        Nothing -> Left "unterminated quote"
        Just ('"', rest) -> Right (acc, rest)
        Just ('\\', rest) | Just (c, rest') <- Text.uncons rest -> go (Text.snoc acc c) rest'
        Just (c, rest) -> go (Text.snoc acc c) rest


-- | Comma-separated names, bare or quoted.
subjects :: Text -> Either String [Text]
subjects t
    | Text.null (Text.strip t) = Right []
    | otherwise = do
        (name, rest) <- word (Text.stripStart t)
        case Text.uncons (Text.stripStart rest) of
            Nothing -> Right [name]
            Just (',', more) -> (name :) <$> subjects more
            Just _ -> Left "expected a comma"