packages feed

valiant-plugin-0.1.0.0: src/Valiant/Plugin/Errors.hs

-- | Rich compile-time error messages for valiant.
module Valiant.Plugin.Errors
  ( errSqlFileNotFound
  , errCacheStaleOrMissing
  , errParamTypeMismatch
  , errParamCountMismatch
  , errResultTypeMismatch
  , errColumnCountMismatch
  ) where

import Data.Text (Text)
import Data.Text qualified as T
import GHC.Plugins (SDoc, text, vcat, (<+>), empty, hcat)
import GHC.Types.SrcLoc (SrcSpan)
import Valiant.Plugin.Cache (CacheColumn (..), CacheParam (..))
import Valiant.Plugin.Compat (emitPluginError)
import GHC.Tc.Utils.Monad (TcM)

-- Shorthand for concatenation without spaces
(<<>>) :: SDoc -> SDoc -> SDoc
a <<>> b = hcat [a, b]

-- | VALIANT-001: SQL file not found.
errSqlFileNotFound :: SrcSpan -> FilePath -> [FilePath] -> TcM ()
errSqlFileNotFound srcSpan path suggestions =
  emitPluginError srcSpan $
    vcat
      [ text "[VALIANT-001] SQL file not found"
      , text ""
      , text "  queryFile references a file that doesn't exist:"
      , text "    " <<>> text path
      , text ""
      , suggestBlock suggestions
      , text "  Fix: correct the filename in your queryFile call."
      ]

suggestBlock :: [FilePath] -> SDoc
suggestBlock [] = empty
suggestBlock xs =
  vcat $
    [text "  Did you mean one of these?"]
      ++ [text "    - " <<>> text x | x <- take 3 xs]
      ++ [text ""]

-- | VALIANT-002: Cache stale or missing.
errCacheStaleOrMissing :: SrcSpan -> FilePath -> TcM ()
errCacheStaleOrMissing srcSpan path =
  emitPluginError srcSpan $
    vcat
      [ text "[VALIANT-002] Query not prepared"
      , text ""
      , text "  No cached metadata found for:"
      , text "    " <<>> text path
      , text ""
      , text "  This means either:"
      , text "    1. You haven't run `valiant prepare` yet, or"
      , text "    2. The SQL file has changed since the last prepare."
      , text ""
      , text "  Fix: run `valiant prepare` then rebuild."
      ]

-- | VALIANT-005: Parameter type mismatch.
errParamTypeMismatch :: SrcSpan -> FilePath -> Int -> Text -> Text -> TcM ()
errParamTypeMismatch srcSpan path paramIdx expected actual =
  emitPluginError srcSpan $
    vcat
      [ text "[VALIANT-005] Parameter type mismatch"
      , text ""
      , text "  Parameter $" <<>> text (show paramIdx)
             <+> text "in" <+> text path
      , text ""
      , text "  Postgres expects:" <+> text (T.unpack expected)
      , text "  Your type:       " <+> text (T.unpack actual)
      , text ""
      , text "  Fix: change the parameter type to" <+> text (T.unpack expected)
      ]

-- | VALIANT-006: Parameter count mismatch.
errParamCountMismatch :: SrcSpan -> FilePath -> Int -> Int -> [CacheParam] -> TcM ()
errParamCountMismatch srcSpan path expected actual params =
  emitPluginError srcSpan $
    vcat $
      [ text "[VALIANT-006] Parameter count mismatch"
      , text ""
      , text "  Your type provides" <+> text (show actual) <+> text "parameter(s)"
      , text "  but the query expects" <+> text (show expected)
      , text ""
      , text "  SQL" <+> text ("(" ++ path ++ ")") <+> text "expects:"
      ]
        ++ [text "    $" <<>> text (show (cpIndex p))
             <+> text "::" <+> text (T.unpack (cpHaskellType p))
           | p <- params]
        ++ [ text ""
           , text "  Fix:" <+> fixHint
           ]
  where
    fixHint
      | expected == 0 = text "use () as the parameter type."
      | expected == 1 = text "use a single type, not a tuple."
      | otherwise = text "use a" <+> text (show expected) <<>> text "-tuple for parameters."

-- | VALIANT-003: Result type mismatch (column type mismatch).
errResultTypeMismatch :: SrcSpan -> FilePath -> [(CacheColumn, Text, Bool)] -> TcM ()
errResultTypeMismatch srcSpan path mismatches =
  emitPluginError srcSpan $
    vcat $
      [ text "[VALIANT-003] Result type mismatch"
      , text ""
      , text "  Your type doesn't match what Postgres returns for" <+> text path
      , text ""
      , text "  Column       PG type         Your type        Match"
      , text "  ──────       ───────         ─────────        ─────"
      ]
        ++ [formatMismatch m | m <- mismatches]
        ++ nullabilityHints
        ++ [ text ""
           , text "  Fix: update your type signature to match the expected types."
           ]
  where
    formatMismatch (col, userType, ok) =
      text "  " <<>> padR 13 (ccName col) <<>> padR 16 (ccPgTypeName col) <<>> padR 17 userType
        <<>> text (if ok then "OK" else "MISMATCH")
    padR n t = text (T.unpack t ++ replicate (max 0 (n - T.length t)) ' ')
    nullabilityHints =
      let hints = concatMap nullHint mismatches
       in if null hints then [] else text "" : hints
    nullHint (col, userType, False)
      | ccNullable col && not (T.isPrefixOf "Maybe" (T.strip userType)) =
          [ text "  Column" <+> text (show (T.unpack (ccName col)))
              <+> text "can be NULL. Wrap in Maybe:" <+> text ("Maybe " ++ T.unpack (T.strip userType))
          , text "  Or force non-null in SQL: SELECT ..." <+> text (T.unpack (ccName col))
              <+> text "AS" <+> text (show (T.unpack (ccName col) ++ "!")) <+> text "..."
          ]
      | not (ccNullable col) && T.isPrefixOf "Maybe" (T.strip userType) =
          [ text "  Column" <+> text (show (T.unpack (ccName col)))
              <+> text "is NOT NULL. Remove the Maybe wrapper."
          ]
    nullHint _ = []

-- | VALIANT-004: Column count mismatch.
errColumnCountMismatch :: SrcSpan -> FilePath -> Int -> Int -> [CacheColumn] -> TcM ()
errColumnCountMismatch srcSpan path expected actual cols =
  emitPluginError srcSpan $
    vcat $
      [ text "[VALIANT-004] Column count mismatch"
      , text ""
      , text "  Your type has" <+> text (show actual) <+> text "field(s)"
      , text "  but the query returns" <+> text (show expected) <+> text "column(s)."
      , text ""
      , text "  SQL" <+> text ("(" ++ path ++ ")") <+> text "returns:"
      ]
        ++ [ text "    " <<>> text (show i) <<>> text "." <+> text (T.unpack (ccName c))
               <+> text "::" <+> text (T.unpack (ccHaskellType c))
           | (i, c) <- zip [(1 :: Int) ..] cols
           ]
        ++ [ text ""
           , text "  Fix: update your result type to have" <+> text (show expected) <+> text "field(s)."
           ]