packages feed

hasql-th-format-0.1.0.0: app/Hasql/ThFormat.hs

module Hasql.ThFormat (
    processFile,
    processBytes,
    ProcessResult (..),
)
where

import Data.ByteString (ByteString)
import Data.ByteString qualified as BS
import Data.ByteString.Lazy qualified as BSL
import Data.Either (lefts, rights)
import Data.List (intercalate, sortBy)
import Data.Ord (Down (..), comparing)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding qualified as TE
import Hasql.Check.Lexer (AlexPosn (..), runAlex)
import Hasql.Check.Parser (QuasiQuote (..), parse)
import Hasql.Check.Quoter (quotersSupported)
import Hasql.ThFormat.SqlFormat (formatSql)
import Prelude


data ProcessResult
    = Unchanged
    | Changed
    | Error String
    deriving (Show, Eq)


-- | Format a ByteString in memory, returning the result. The Alex lexer
-- requires a lazy ByteString so we convert at this single boundary; the rest
-- of the pipeline works on strict bytes. Returns 'Left' with a
-- newline-separated list of per-QQ errors if the formatter ever emitted
-- AST-divergent SQL; the bytes are left untouched in that case.
processBytes :: ByteString -> Either String ByteString
processBytes original =
    case runAlex (BSL.fromStrict original) parse of
        Left err -> Left err
        Right qqs ->
            let supported = reverse (filter isSupportedQuoter qqs)
                results = map makeReplacement supported
                errors = lefts results
                replacements = rights results
             in if null errors
                    then Right (applyReplacements original (sortReplacements replacements))
                    else Left (intercalate "\n" errors)


processFile :: Bool -> FilePath -> IO ProcessResult
processFile checkOnly filePath = do
    original <- BS.readFile filePath
    case processBytes original of
        Left err -> pure (Error err)
        Right modified
            | original == modified -> pure Unchanged
            | checkOnly -> pure Changed
            | otherwise -> do
                BS.writeFile filePath modified
                pure Changed


isSupportedQuoter :: QuasiQuote -> Bool
isSupportedQuoter (QuasiQuote (_, quoter) _) = quoter `elem` quotersSupported


data Replacement = Replacement
    { rStartOffset :: Int
    , rEndOffset :: Int
    , rFormatted :: Text
    }
    deriving (Show)


makeReplacement :: QuasiQuote -> Either String Replacement
makeReplacement (QuasiQuote (AlexPn quoterOffset quoterLine quoterCol, quoterName) (AlexPn endOffset _ _, sqlContent)) =
    let startOffset = quoterOffset + 1 + T.length quoterName + 1
        -- quoterCol is 1-based column of '[', so the base indent for SQL is (quoterCol - 1) spaces
        qqIndent = quoterCol - 1
     in case formatSql qqIndent sqlContent of
            Right formatted ->
                Right
                    Replacement
                        { rStartOffset = startOffset
                        , rEndOffset = endOffset
                        , rFormatted = formatted
                        }
            Left reason -> Left ("line " <> show quoterLine <> ": " <> T.unpack reason)


sortReplacements :: [Replacement] -> [Replacement]
sortReplacements = sortBy (comparing (Down . rStartOffset))


applyReplacements :: ByteString -> [Replacement] -> ByteString
applyReplacements = foldl applyOne
  where
    applyOne :: ByteString -> Replacement -> ByteString
    applyOne bs Replacement{rStartOffset, rEndOffset, rFormatted} =
        let before = BS.take rStartOffset bs
            after = BS.drop rEndOffset bs
            formattedBS = TE.encodeUtf8 rFormatted
         in before <> formattedBS <> after