packages feed

hasql-check-ghc-0.5.1.0: src/Hasql/Check/Ghc/Source.hs

{- |
Module      : Hasql.Check.Ghc.Source
Copyright   : (c) 2026 Kronor.io

License     : BSD-3-Clause
Maintainer  : lorenzo@kronor.io
Stability   : experimental
Portability : GHC

A statement of "Hasql.Check.Inferred.TH", from what GHC knows of it: its
quote, found in its source from the span GHC gives the code it generated,
and its types, from the dictionaries that code is passed. Whether GHC's
types come from a @.hie@ file or a compiler plugin, they are read here.

GHC counts columns from 1, and a tab as reaching the next tab stop, every 8
columns; the source text counts characters.
-}
module Hasql.Check.Ghc.Source (
    -- * Statements
    Statement (..),
    Column (..),
    statementAt,
    statementOf,
    typeName,

    -- * Quotes
    Quote (..),
    quoteAt,
    Types (..),
    typesOf,
    Ty (..),
    ground,
    columnTypes,
    symbols,
    labelLists,
    charIndex,
    ghcColumn,
) where

import Data.Char (isAlphaNum, isDigit)
import Data.List (intercalate)

data Quote = Quote
    { column :: Int
    -- ^ GHC's column of the quote's opening bracket.
    , quoter :: String
    , body :: String
    }
    deriving stock (Eq, Show)

{- | The quote a span covers, given the source's lines and the span's start
and end, as GHC's line and column. The span starts right after
@[quoter|@ and ends right after @|]@; any other span is not a quote's.
-}
quoteAt :: [String] -> (Int, Int) -> (Int, Int) -> Maybe Quote
quoteAt source (startLine, startColumn) (endLine, endColumn) = do
    first <- line startLine
    final <- line endLine
    let start = charIndex first startColumn
        end = charIndex final endColumn - 2
        opening = take (start - 1) first
        bracket = lastIndex '[' opening
        name = drop (bracket + 1) opening
    if start >= 1
        && take 1 (drop (start - 1) first) == "|"
        && bracket >= 0
        && not (null name)
        && all (\c -> isAlphaNum c || c `elem` ("._'" :: String)) name
        then
            Just
                Quote
                    { column = ghcColumn first bracket
                    , quoter = name
                    , body =
                        if startLine == endLine
                            then take (end - start) (drop start first)
                            else
                                intercalate "\n" $
                                    [drop start first]
                                        <> [l | n <- [startLine + 1 .. endLine - 1], Just l <- [line n]]
                                        <> [take end final]
                    }
        else Nothing
  where
    line n
        | n >= 1, l : _ <- drop (n - 1) source = Just l
        | otherwise = Nothing
    lastIndex c s = case [i | (i, x) <- zip [0 ..] s, x == c] of
        [] -> -1
        is -> last is

-- | What the dictionaries in a quote's span say about its statement's types.
data Types t
    = -- | Not a statement of Hasql.Check.Inferred.TH.
      NotInferred
    | {- | Its types are only known where it is used, and may differ from one
      use to the next.
      -}
      Unresolved
    | -- | Its parameter type, and its row type unless it returns no rows.
      Resolved t (Maybe t)
    deriving stock (Eq, Show)

{- | The statement's types, given which types are concrete, and the types of
the quote's Params and Row dictionaries.
-}
typesOf :: (t -> Bool) -> [t] -> [t] -> Types t
typesOf concrete params rows = case (filter concrete params, filter concrete rows) of
    _ | null params -> NotInferred
    ([], _) -> Unresolved
    (_, []) | not (null rows) -> Unresolved
    (p : _, r) -> Resolved p (case r of x : _ -> Just x; [] -> Nothing)

-- | The index in a line of the character at a GHC column.
charIndex :: String -> Int -> Int
charIndex = go 0 1
  where
    go i c rest target
        | c >= target = i
        | x : xs <- rest = go (i + 1) (advance c x) xs target
        | otherwise = i

-- | GHC's column of the character at an index in a line.
ghcColumn :: String -> Int -> Int
ghcColumn s i = foldl advance 1 (take i s)

advance :: Int -> Char -> Int
advance c = \case
    '\t' -> ((c - 1) `div` 8 + 1) * 8 + 1
    _ -> c + 1

{- | A type: a constructor applied to its visible arguments, by module and
name; a string literal, which names a Postgres type or label; another
literal; or something not concrete.
-}
data Ty = Con String String [Ty] | Lit String | OtherLit String | Var
    deriving stock (Eq, Show)

-- | Whether a type is concrete.
ground :: Ty -> Bool
ground = \case
    Var -> False
    Lit _ -> True
    OtherLit _ -> True
    Con _ _ args -> all ground args

{- | A parameter or row type's columns, each with whether it is a 'Maybe':
a tuple's elements, none for unit as parameters, or the type itself.
-}
columnTypes :: Bool -> Ty -> [(Ty, Bool)]
columnTypes isParams t = map column (split t)
  where
    split = \case
        Con m "Unit" [] | isParams, m `elem` tupleModules -> []
        Con m name args | m `elem` tupleModules, isTuple name -> args
        other -> [other]
    column = columnOf

-- | A column's type and whether it is a 'Maybe', so may be NULL.
columnOf :: Ty -> (Ty, Bool)
columnOf = \case
    Con m "Maybe" [inner] | m `elem` maybeModules -> (inner, True)
    other -> (other, False)

{- | Where GHC defines tuples and unit, and Maybe: a type of the same name
elsewhere, a newtype TupleId say, is a type of its own.
-}
tupleModules, maybeModules :: [String]
tupleModules = ["GHC.Tuple", "GHC.Tuple.Prim"]
maybeModules = ["GHC.Internal.Maybe", "GHC.Maybe"]

isTuple :: String -> Bool
isTuple name = case splitAt 5 name of
    ("Tuple", n) -> not (null n) && all isDigit n
    _ -> False

-- * Statements

-- | A statement, with what the checker needs of it.
data Statement = Statement
    { line :: Int
    , quote :: Quote
    , params :: [Column]
    , row :: Maybe [Column]
    -- ^ Nothing for a statement that returns no rows.
    }

-- | A parameter or result column.
data Column = Column
    { haskellType :: String
    , postgres :: String
    , labels :: [String]
    -- ^ The enum's labels, if it is one.
    , nullable :: Bool
    }

{- | The statement whose generated code has the given span, given the source
lines and the types of the dictionaries the code is passed there: nothing
if the span is not a quote of these quoters, an error if its types are not
concrete there.
-}
statementAt :: [String] -> (Int, Int) -> (Int, Int) -> [Ty] -> Maybe (Either String Statement)
statementAt source start@(line', _) end here = do
    q <- quoteAt source start end
    statementOf line' q here

{- | The statement of a quote on a line, given the types of the dictionaries
its generated code is passed: nothing if it is not a quote of these
quoters, an error if its types are not concrete there.
-}
statementOf :: Int -> Quote -> [Ty] -> Maybe (Either String Statement)
statementOf line' q here = case first paramHs of
    -- The quoters build the encoder and the decoder themselves, so the types
    -- come one per placeholder and per column rather than as a tuple to
    -- split: a record target and a param field are neither.
    Just hs
        | Just names <- first paramNames
        , Just labels' <- first paramLabels ->
            Just (Right (Statement line' q (columnsOf (map columnOf hs) names labels') resultRow))
        | otherwise -> Just (Left unresolved)
    Nothing -> case typesOf ground params' rows of
        NotInferred -> Nothing
        Resolved p r
            | Just names <- first paramNames
            , Just labels' <- first paramLabels
            , Just row' <- traverse (\r' -> (r',,) <$> first columnNames <*> first columnLabels) r ->
                Just (Right (Statement line' q (columns True (p, names, labels')) (columns False <$> row')))
        _ -> Just (Left unresolved)
  where
    unresolved =
        "the statement's types are not known where it is written, so it cannot be checked; give its binding a concrete type, such as Statement Int64 Text"
    -- A statement that returns rows carries either the column types the
    -- quoter collected, or, where it could not count them, the Haskell row
    -- type's. One that returns none carries neither.
    resultRow = case (first columnNames, first columnLabels) of
        (Just names, Just labels') -> case (first columnHs, filter ground rows) of
            (Just hs, _) -> Just (columnsOf (map columnOf hs) names labels')
            (Nothing, r : _) -> Just (columnsOf (columnTypes False r) names labels')
            _ -> Nothing
        _ -> Nothing
    params' = [p | Con "Hasql.Check.Inferred" "Params" [p] <- here]
    rows = [r | Con "Hasql.Check.Inferred" "Row" [r] <- here]
    paramHs = [hs | Con "Hasql.Check.Inferred" "HaskellParams" [l] <- here, Just hs <- [types l], all ground hs]
    columnHs = [hs | Con "Hasql.Check.Inferred" "HaskellColumns" [l] <- here, Just hs <- [types l], all ground hs]
    paramNames = [ns | Con "Hasql.Check.Inferred" "PostgresParams" [l] <- here, Just ns <- [symbols l]]
    columnNames = [ns | Con "Hasql.Check.Inferred" "PostgresColumns" [l] <- here, Just ns <- [symbols l]]
    paramLabels = [ls | Con "Hasql.Check.Inferred" "PostgresParamLabels" [l] <- here, Just ls <- [labelLists l]]
    columnLabels = [ls | Con "Hasql.Check.Inferred" "PostgresColumnLabels" [l] <- here, Just ls <- [labelLists l]]
    first = \case
        x : _ -> Just x
        [] -> Nothing
    columns isParams (t, names, labels') = columnsOf (columnTypes isParams t) names labels'
    columnsOf cs names labels' =
        zipWith3 (\(c, nullable') postgres' ls -> Column (typeName c) postgres' ls nullable') cs names labels'

-- | A type as Haskell would write it, qualified.
typeName :: Ty -> String
typeName = \case
    Con m n args -> unwords ((if null m then n else m <> "." <> n) : map (parenthesised . typeName) args)
    Lit s -> show s
    OtherLit s -> s
    Var -> "?"
  where
    parenthesised name = if ' ' `elem` name then concat ["(", name, ")"] else name

{- | A type-level list of strings. The quoters build theirs by appending,
and GHC records the Append unreduced, so it is evaluated here.
-}
symbols :: Ty -> Maybe [String]
symbols = \case
    Con _ ":" [Lit s, rest] -> (s :) <$> symbols rest
    Con _ "[]" [] -> Just []
    Con _ "Append" [a, b] -> (<>) <$> symbols a <*> symbols b
    _ -> Nothing

{- | A type-level list of types, as 'Slots' and 'Target' carry: one per
placeholder or result column, which a record target or a param field makes
something other than a tuple's elements.
-}
types :: Ty -> Maybe [Ty]
types = \case
    Con _ ":" [t, rest] -> (t :) <$> types rest
    Con _ "[]" [] -> Just []
    Con _ "Append" [a, b] -> (<>) <$> types a <*> types b
    _ -> Nothing

-- | A type-level list of lists of strings.
labelLists :: Ty -> Maybe [[String]]
labelLists = \case
    Con _ ":" [l, rest] -> (:) <$> symbols l <*> labelLists rest
    Con _ "[]" [] -> Just []
    Con _ "Append" [a, b] -> (<>) <$> labelLists a <*> labelLists b
    _ -> Nothing