packages feed

hasql-check-ghc-0.5.0.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 = \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 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
                    "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"
                )
  where
    params' = [p | Con "Hasql.Check.Inferred" "Params" [p] <- here]
    rows = [r | Con "Hasql.Check.Inferred" "Row" [r] <- here]
    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') =
        zipWith3 (\(c, nullable') postgres' ls -> Column (typeName c) postgres' ls nullable') (columnTypes isParams t) 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.
symbols :: Ty -> Maybe [String]
symbols = \case
    Con _ ":" [Lit s, rest] -> (s :) <$> symbols rest
    Con _ "[]" [] -> Just []
    _ -> Nothing


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