packages feed

wireform-derive-0.1.0.0: src/Wireform/Derive/NameStyle.hs

-- | Rich DSL for transforming Haskell field selector names into
-- on-the-wire field names.
--
-- 'NameStyle' is intentionally a pure ADT with a 'Data' instance so that
-- it can survive splice-time annotation reflection: a 'NameStyle' value
-- attached via @ANN@ can be reified, fully evaluated at compile time,
-- and inlined into the generated encode/decode code as a literal 'Text'.
--
-- A handful of constructors (notably 'Idiomatic') refer to the active
-- backend rather than to a fixed transformation. These are resolved by
-- 'resolveIdiomatic' before 'applyStyle' is run.
module Wireform.Derive.NameStyle
  ( -- * The style DSL
    NameStyle (..)
  , andThen
    -- * Application
  , applyStyle
    -- * Idiomatic resolution
  , idiomaticFor
  , resolveIdiomatic
    -- * Building blocks (re-exported for testing)
  , toSnakeCase
  , toUpperSnake
  , toKebabCase
  , toUpperKebab
  , toCamelCase
  , toPascalCase
  ) where

import Control.DeepSeq (NFData)
import Data.Char (isLower, isUpper, toUpper)
import Data.Data (Data)
import Data.Hashable (Hashable)
import Data.Text (Text)
import qualified Data.Text as T
import GHC.Generics (Generic)
import Language.Haskell.TH.Syntax (Lift)

import Wireform.Derive.Backend

-- | A composable rename strategy. 'NameStyle' is a closed ADT so that
-- per-format derivers can interpret it at splice time and bake the
-- result into generated wire-key 'Text' literals (zero runtime cost).
data NameStyle
  = -- | @snake_case@. Boundaries inferred from camelCase / PascalCase
    -- humps: @"personName"@ becomes @"person_name"@.
    SnakeCase
  | -- | @SCREAMING_SNAKE_CASE@.
    UpperSnake
  | -- | @kebab-case@.
    KebabCase
  | -- | @SCREAMING-KEBAB-CASE@.
    UpperKebab
  | -- | @lowerCamelCase@. The first character is lowercased; subsequent
    -- humps preserved.
    CamelCase
  | -- | @UpperCamelCase@ / @PascalCase@.
    PascalCase
  | -- | All lowercase.
    LowerCase
  | -- | All uppercase.
    UpperCase
  | -- | Strip a literal prefix if present; otherwise leave the input
    -- unchanged.
    StripPrefix !Text
  | -- | Strip a literal suffix if present; otherwise unchanged.
    StripSuffix !Text
  | -- | Strip a prefix case-insensitively.
    StripPrefixCI !Text
  | -- | Strip a suffix case-insensitively.
    StripSuffixCI !Text
  | -- | Drop @n@ characters from the start of the input.
    DropChars !Int
  | -- | Take only the first @n@ characters of the input.
    TakeChars !Int
  | -- | Replace every occurrence of the first 'Text' with the second.
    Replace !Text !Text
  | -- | Replace only the first occurrence.
    ReplaceFirst !Text !Text
  | -- | Sequential composition: @Compose a b@ first applies @a@, then
    -- @b@.
    Compose !NameStyle !NameStyle
  | -- | Identity transformation.
    NoStyle
  | -- | Apply the active backend's idiomatic naming convention. This
    -- constructor is resolved by 'resolveIdiomatic' before
    -- 'applyStyle' is called.
    Idiomatic
  deriving stock (Eq, Ord, Show, Data, Generic, Lift)
  deriving anyclass (NFData, Hashable)

-- | Left-to-right composition. @a `andThen` b@ first applies @a@, then
-- @b@.
andThen :: NameStyle -> NameStyle -> NameStyle
andThen = Compose
infixl 1 `andThen`

-- | Substitute every 'Idiomatic' marker with the concrete style for
-- the given backend.
resolveIdiomatic :: Backend -> NameStyle -> NameStyle
resolveIdiomatic b = go
  where
    go Idiomatic        = idiomaticFor b
    go (Compose x y)    = Compose (go x) (go y)
    go s                = s

-- | The conventional rename style for a given backend.
--
-- Defaults follow the dominant on-the-wire convention for each format:
--
-- * 'backendJSON' / 'backendProto' → 'CamelCase' (proto3's @json_name@
--   default is @lowerCamelCase@; common JS / TS APIs use the same).
-- * 'backendEDN' / 'backendYAML' → 'KebabCase'.
-- * 'backendTOML' → 'SnakeCase'.
-- * 'backendXML' → 'PascalCase'.
-- * 'backendCBOR' / 'backendMsgPack' / 'backendThrift' / 'backendBinary' /
--   'backendCSV' / 'backendTextFormat' → 'NoStyle' (selector base used
--   verbatim).
--
-- Downstream backends not listed here fall through to 'NoStyle'.
idiomaticFor :: Backend -> NameStyle
idiomaticFor b
  | b == backendJSON        = CamelCase
  | b == backendNDJSON      = CamelCase
  | b == backendProto       = CamelCase
  | b == backendBSON        = CamelCase
  | b == backendION         = CamelCase
  | b == backendEDN         = KebabCase
  | b == backendYAML        = KebabCase
  | b == backendHTML        = KebabCase
  | b == backendTOML        = SnakeCase
  | b == backendAvro        = SnakeCase
  | b == backendBond        = SnakeCase
  | b == backendArrow       = SnakeCase
  | b == backendParquet     = SnakeCase
  | b == backendOrc         = SnakeCase
  | b == backendIceberg     = SnakeCase
  | b == backendXML         = PascalCase
  | b == backendCBOR        = NoStyle
  | b == backendMsgPack     = NoStyle
  | b == backendThrift      = NoStyle
  | b == backendBinary      = NoStyle
  | b == backendCSV         = NoStyle
  | b == backendTextFormat  = NoStyle
  | b == backendASN1        = NoStyle  -- ASN.1 names are positional
  | b == backendBencode     = NoStyle  -- byte-string keys, verbatim
  | b == backendFlatBuffers = SnakeCase
  | b == backendCapnProto   = CamelCase
  | b == backendFory        = SnakeCase
  | otherwise               = NoStyle

-- | Apply a style. 'Idiomatic' constructors that have not been
-- resolved by 'resolveIdiomatic' degrade to 'NoStyle' rather than
-- raise.
applyStyle :: NameStyle -> Text -> Text
applyStyle = go
  where
    go SnakeCase          = toSnakeCase
    go UpperSnake         = T.toUpper . toSnakeCase
    go KebabCase          = toKebabCase
    go UpperKebab         = T.toUpper . toKebabCase
    go CamelCase          = toCamelCase
    go PascalCase         = toPascalCase
    go LowerCase          = T.toLower
    go UpperCase          = T.toUpper
    go (StripPrefix p)    = stripPrefixCS p
    go (StripSuffix s)    = stripSuffixCS s
    go (StripPrefixCI p)  = stripPrefixCI p
    go (StripSuffixCI s)  = stripSuffixCI s
    go (DropChars n)      = T.drop n
    go (TakeChars n)      = T.take n
    go (Replace o n)      = T.replace o n
    go (ReplaceFirst o n) = replaceFirst o n
    go (Compose a b)      = applyStyle b . applyStyle a
    go NoStyle            = id
    go Idiomatic          = id

-- ---------------------------------------------------------------------------
-- Hump-aware splitting
-- ---------------------------------------------------------------------------

-- | Split an identifier into lowercase \"words\" on:
--
-- * existing @_@ / @-@ / spaces, and
-- * camelCase / PascalCase humps (so @PersonName@ → @["person", "name"]@,
--   and @HTTPRequest@ → @["http", "request"]@).
--
-- Used by every snake / kebab / camel transformation.
splitWords :: Text -> [Text]
splitWords =
  filter (not . T.null)
    . map T.toLower
    . concatMap (map T.pack . splitCamelStr . T.unpack)
    . T.split isPunct
  where
    isPunct c = c == '_' || c == '-' || c == ' '

-- | Insert word boundaries at every @lower → upper@ transition and at
-- every @upper-run → (Upper, lower)@ transition. E.g.
-- @"HTTPRequest"@ → @["HTTP", "Request"]@.
splitCamelStr :: String -> [String]
splitCamelStr [] = []
splitCamelStr (c0 : cs0) = go [c0] cs0
  where
    -- @go acc xs@ accumulates the current word in reverse in @acc@.
    go :: String -> String -> [String]
    go acc []                                          = [reverse acc]
    go acc@(prev : _) (x : xs)
      | isLower prev && isUpper x                      = reverse acc : go [x] xs
      | isUpper x, isAcronymRun acc, peekLower xs      =
          -- We are inside an upper-run and the *next* char after @x@
          -- starts a lowercase tail. Close the acronym at @acc@; @x@
          -- begins the new word.
          reverse acc : go [x] xs
      | otherwise                                      = go (x : acc) xs
    go [] _ = error "splitCamelStr: impossible empty acc"

    isAcronymRun :: String -> Bool
    isAcronymRun s = length s >= 1 && all isUpper s

    peekLower :: String -> Bool
    peekLower (y : _) = isLower y
    peekLower []      = False

-- | Lowercase, underscore-separated.
toSnakeCase :: Text -> Text
toSnakeCase = T.intercalate "_" . splitWords

-- | Uppercase, underscore-separated.
toUpperSnake :: Text -> Text
toUpperSnake = T.toUpper . toSnakeCase

-- | Lowercase, hyphen-separated.
toKebabCase :: Text -> Text
toKebabCase = T.intercalate "-" . splitWords

-- | Uppercase, hyphen-separated.
toUpperKebab :: Text -> Text
toUpperKebab = T.toUpper . toKebabCase

-- | @lowerCamelCase@.
toCamelCase :: Text -> Text
toCamelCase t = case splitWords t of
  []     -> T.empty
  (w:ws) -> T.concat (w : map capitalise ws)

-- | @UpperCamelCase@ / @PascalCase@.
toPascalCase :: Text -> Text
toPascalCase = T.concat . map capitalise . splitWords

capitalise :: Text -> Text
capitalise t = case T.uncons t of
  Nothing      -> t
  Just (c, cs) -> T.cons (toUpper c) cs

-- ---------------------------------------------------------------------------
-- Affix stripping
-- ---------------------------------------------------------------------------

stripPrefixCS :: Text -> Text -> Text
stripPrefixCS p t = case T.stripPrefix p t of
  Just rest -> rest
  Nothing   -> t

stripSuffixCS :: Text -> Text -> Text
stripSuffixCS s t = case T.stripSuffix s t of
  Just rest -> rest
  Nothing   -> t

stripPrefixCI :: Text -> Text -> Text
stripPrefixCI p t
  | T.length t >= T.length p
  , T.toLower (T.take (T.length p) t) == T.toLower p
    = T.drop (T.length p) t
  | otherwise = t

stripSuffixCI :: Text -> Text -> Text
stripSuffixCI s t
  | T.length t >= T.length s
  , T.toLower (T.takeEnd (T.length s) t) == T.toLower s
    = T.dropEnd (T.length s) t
  | otherwise = t

-- ---------------------------------------------------------------------------
-- Misc helpers
-- ---------------------------------------------------------------------------

replaceFirst :: Text -> Text -> Text -> Text
replaceFirst needle replacement haystack
  | T.null needle = haystack
  | otherwise = case T.breakOn needle haystack of
      (before, after) ->
        case T.stripPrefix needle after of
          Just rest -> before <> replacement <> rest
          Nothing   -> haystack