keiro-dsl-0.11.0.0: src/Keiro/Dsl/HaskellName.hs
-- | Checked names for every Haskell declaration generated by keiro-dsl.
--
-- Logical DSL names and external spellings remain ordinary 'Text'. A value
-- crosses into generated Haskell only through the total constructors in this
-- module, which share one ASCII word-segmentation policy and one keyword set.
module Keiro.Dsl.HaskellName
( UpperCamelName,
LowerCamelName,
HaskellModuleSegment,
HaskellModuleName,
DerivedHaskellName (..),
NameSourceKind (..),
NameSiteKind (..),
HaskellOccurrenceSpace (..),
NameSite (..),
HaskellOccurrenceKey (..),
PlannedOccurrence (..),
HaskellNameError (..),
GeneratedHaskellNamingEdition (..),
currentGeneratedHaskellNamingEdition,
renderGeneratedHaskellNamingEdition,
parseGeneratedHaskellNamingEdition,
deriveHaskellName,
checkedModuleSegment,
checkedModuleName,
checkedUpperOccurrence,
checkedLowerOccurrence,
plannedOccurrence,
detectNameCollisions,
renderUpperCamelName,
renderLowerCamelName,
renderModuleSegment,
renderModuleName,
haskellKeywords,
)
where
import Data.Char (toLower, toUpper)
import Data.List (sortOn)
import Data.List.NonEmpty (NonEmpty (..))
import Data.Map.Strict qualified as Map
import Data.Maybe (listToMaybe)
import Data.Set (Set)
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as T
newtype UpperCamelName = UpperCamelName Text
deriving stock (Eq, Ord, Show)
newtype LowerCamelName = LowerCamelName Text
deriving stock (Eq, Ord, Show)
newtype HaskellModuleSegment = HaskellModuleSegment Text
deriving stock (Eq, Ord, Show)
newtype HaskellModuleName = HaskellModuleName Text
deriving stock (Eq, Ord, Show)
data DerivedHaskellName = DerivedHaskellName
{ upperCamel :: !UpperCamelName,
lowerCamel :: !LowerCamelName
}
deriving stock (Eq, Show)
data NameSourceKind
= LogicalIdentifier
| LogicalWireWord
| ExplicitHaskellName
deriving stock (Eq, Ord, Show)
-- | Stable declaration categories used in diagnostics and source-move roles.
data NameSiteKind
= ContextModuleSite
| NodeModuleSite
| GeneratedTypeSite
| GeneratedConstructorSite
| GeneratedValueSite
| GeneratedFieldSite
| GeneratedHelperSite
| ImportAliasSite
deriving stock (Eq, Ord, Enum, Bounded, Show)
data HaskellOccurrenceSpace
= ModuleSpace
| TypeSpace
| ConstructorSpace
| ValueSpace
| FieldSpace
deriving stock (Eq, Ord, Enum, Bounded, Show)
-- | The source declaration responsible for a generated occurrence. The
-- owner and kind form a stable identity; the line is evidence, not identity.
data NameSite = NameSite
{ siteKind :: !NameSiteKind,
siteLogicalName :: !Text,
siteOwner :: !Text,
siteLine :: !Int
}
deriving stock (Eq, Ord, Show)
-- | A collision key. Module names are compared case-insensitively because
-- generated trees must remain safe on case-insensitive filesystems. Scope is
-- normally empty; record fields use their owning record so
-- DuplicateRecordFields can keep identical selectors on different records.
data HaskellOccurrenceKey = HaskellOccurrenceKey
{ occurrenceModule :: !Text,
occurrenceSpace :: !HaskellOccurrenceSpace,
occurrenceScope :: !Text,
occurrenceName :: !Text
}
deriving stock (Eq, Ord, Show)
data PlannedOccurrence = PlannedOccurrence
{ plannedOccurrenceKey :: !HaskellOccurrenceKey,
plannedOccurrenceSite :: !NameSite
}
deriving stock (Eq, Ord, Show)
data HaskellNameError
= EmptyNameSegment !NameSite
| UnsafeNameSeparator !NameSite !Text
| ReservedGeneratedOccurrence !NameSite !Text
| NormalizedNameCollision !HaskellOccurrenceKey !(NonEmpty NameSite)
| InvalidExplicitHaskellName !NameSite !Text
deriving stock (Eq, Show)
data GeneratedHaskellNamingEdition
= LegacyNamingV1
| IdiomaticNamingV1
deriving stock (Eq, Ord, Show)
currentGeneratedHaskellNamingEdition :: GeneratedHaskellNamingEdition
currentGeneratedHaskellNamingEdition = IdiomaticNamingV1
renderGeneratedHaskellNamingEdition :: GeneratedHaskellNamingEdition -> Text
renderGeneratedHaskellNamingEdition = \case
LegacyNamingV1 -> "legacy-v1"
IdiomaticNamingV1 -> "idiomatic-v1"
parseGeneratedHaskellNamingEdition :: Text -> Maybe GeneratedHaskellNamingEdition
parseGeneratedHaskellNamingEdition = \case
"legacy-v1" -> Just LegacyNamingV1
"idiomatic-v1" -> Just IdiomaticNamingV1
_ -> Nothing
renderUpperCamelName :: UpperCamelName -> Text
renderUpperCamelName (UpperCamelName value) = value
renderLowerCamelName :: LowerCamelName -> Text
renderLowerCamelName (LowerCamelName value) = value
renderModuleSegment :: HaskellModuleSegment -> Text
renderModuleSegment (HaskellModuleSegment value) = value
renderModuleName :: HaskellModuleName -> Text
renderModuleName (HaskellModuleName value) = value
-- | Derive both Haskell cases from exactly the same word list. Explicit
-- Haskell references are checked by the namespace-specific smart constructors
-- below and are intentionally never re-cased here.
deriveHaskellName :: NameSourceKind -> NameSite -> Either HaskellNameError DerivedHaskellName
deriveHaskellName source site
| source == ExplicitHaskellName = Left (InvalidExplicitHaskellName site (siteLogicalName site))
| otherwise = do
words' <- segmentLogicalName (source == LogicalWireWord) site
case words' of
[] -> Left (EmptyNameSegment site)
firstWord : restWords -> do
let upper = T.concat (map upperWord words')
lower = lowerFirstWord firstWord <> T.concat (map upperWord restWords)
upperName <- checkedUpperOccurrence site upper
lowerName <- checkedLowerOccurrence site lower
pure DerivedHaskellName {upperCamel = upperName, lowerCamel = lowerName}
checkedModuleSegment :: NameSite -> Text -> Either HaskellNameError HaskellModuleSegment
checkedModuleSegment site candidate
| validUpperIdentifier False candidate = Right (HaskellModuleSegment candidate)
| otherwise = Left (InvalidExplicitHaskellName site candidate)
checkedModuleName :: NameSite -> Text -> Either HaskellNameError HaskellModuleName
checkedModuleName site candidate
| not (null components) && all (validUpperIdentifier True) components = Right (HaskellModuleName candidate)
| otherwise = Left (InvalidExplicitHaskellName site candidate)
where
components = T.splitOn "." candidate
checkedUpperOccurrence :: NameSite -> Text -> Either HaskellNameError UpperCamelName
checkedUpperOccurrence site candidate
| validUpperIdentifier False candidate = Right (UpperCamelName candidate)
| otherwise = Left (InvalidExplicitHaskellName site candidate)
checkedLowerOccurrence :: NameSite -> Text -> Either HaskellNameError LowerCamelName
checkedLowerOccurrence site candidate
| Set.member candidate haskellKeywords = Left (ReservedGeneratedOccurrence site candidate)
| validLowerIdentifier candidate = Right (LowerCamelName candidate)
| otherwise = Left (InvalidExplicitHaskellName site candidate)
-- | Construct a collision input after the occurrence has passed its checked
-- wrapper. Field scope is the owning record name; use the empty string for
-- every other namespace.
plannedOccurrence :: Text -> HaskellOccurrenceSpace -> Text -> Text -> NameSite -> PlannedOccurrence
plannedOccurrence targetModule space scope rendered site =
PlannedOccurrence
{ plannedOccurrenceKey =
HaskellOccurrenceKey
{ occurrenceModule = T.toCaseFold targetModule,
occurrenceSpace = space,
occurrenceScope = T.toCaseFold scope,
occurrenceName = case space of
ModuleSpace -> T.toCaseFold rendered
_ -> rendered
},
plannedOccurrenceSite = site
}
-- | Report deterministic collisions independently of declaration traversal
-- order. Repeated inventory entries for the same stable site are collapsed.
detectNameCollisions :: [PlannedOccurrence] -> [HaskellNameError]
detectNameCollisions occurrences =
[ NormalizedNameCollision key (first :| rest)
| (key, sites) <- Map.toAscList grouped,
let orderedSites = Set.toAscList sites,
first : rest <- [orderedSites]
]
where
grouped =
Map.filter ((> 1) . Set.size) $
Map.fromListWith
Set.union
[ (plannedOccurrenceKey occurrence, Set.singleton (plannedOccurrenceSite occurrence))
| occurrence <- sortOn plannedOccurrenceKey occurrences
]
segmentLogicalName :: Bool -> NameSite -> Either HaskellNameError [Text]
segmentLogicalName allowHyphen site
| T.null raw = Left (EmptyNameSegment site)
| badUnderscore = Left (UnsafeNameSeparator site "underscore separators must be single and may not lead or trail a name")
| not allowHyphen && T.any (== '-') raw = Left (UnsafeNameSeparator site "hyphens are allowed only in wire-word sources")
| allowHyphen && badHyphen = Left (UnsafeNameSeparator site "hyphen separators must be single and may not lead or trail a name")
| any T.null separated = Left (EmptyNameSegment site)
| otherwise = Right (concatMap splitCamelWord separated)
where
raw = siteLogicalName site
badUnderscore =
T.isPrefixOf "_" raw
|| T.isSuffixOf "_" raw
|| T.isInfixOf "__" raw
badHyphen =
T.isPrefixOf "-" raw
|| T.isSuffixOf "-" raw
|| T.isInfixOf "--" raw
separated = T.split (`elem` separators) raw
separators = if allowHyphen then ['_', '-'] else ['_']
splitCamelWord :: Text -> [Text]
splitCamelWord value = map T.pack (go [] (T.unpack value))
where
go current [] = [reverse current | not (null current)]
go [] (character : rest) = go [character] rest
go current@(previous : _) remaining@(character : rest)
| camelBoundary previous character (listToMaybe rest) = reverse current : go [] remaining
| otherwise = go (character : current) rest
camelBoundary :: Char -> Char -> Maybe Char -> Bool
camelBoundary previous current next =
(asciiLower previous || asciiDigit previous) && asciiUpper current
|| asciiUpper previous && asciiUpper current && maybe False asciiLower next
upperWord :: Text -> Text
upperWord word = case T.uncons word of
Nothing -> ""
Just (first, rest) -> T.cons (asciiToUpper first) rest
lowerFirstWord :: Text -> Text
lowerFirstWord word
| hasUpper && T.all (\character -> not (asciiLower character)) word = T.map asciiToLower word
| otherwise = case T.uncons word of
Nothing -> ""
Just (first, rest) -> T.cons (asciiToLower first) rest
where
hasUpper = T.any asciiUpper word
validUpperIdentifier :: Bool -> Text -> Bool
validUpperIdentifier allowUnderscore name = case T.uncons name of
Just (first, rest) -> asciiUpper first && T.all (identifierTail allowUnderscore) rest
Nothing -> False
validLowerIdentifier :: Text -> Bool
validLowerIdentifier name = case T.uncons name of
Just (first, rest) -> asciiLower first && T.all (identifierTail False) rest
Nothing -> False
identifierTail :: Bool -> Char -> Bool
identifierTail allowUnderscore character =
asciiUpper character
|| asciiLower character
|| asciiDigit character
|| character == '\''
|| allowUnderscore && character == '_'
asciiUpper :: Char -> Bool
asciiUpper character = character >= 'A' && character <= 'Z'
asciiLower :: Char -> Bool
asciiLower character = character >= 'a' && character <= 'z'
asciiDigit :: Char -> Bool
asciiDigit character = character >= '0' && character <= '9'
asciiToUpper :: Char -> Char
asciiToUpper character
| asciiLower character = toUpper character
| otherwise = character
asciiToLower :: Char -> Char
asciiToLower character
| asciiUpper character = toLower character
| otherwise = character
-- | Words GHC rejects as term-level identifiers under the generated manifest
-- contract (GHC2024 plus the closed local-extension set in ADR 0019). Contextual
-- words are deliberately absent; widening this refusal set requires compile
-- evidence against that exact contract.
haskellKeywords :: Set Text
haskellKeywords =
Set.fromList
[ "case",
"class",
"data",
"default",
"deriving",
"do",
"else",
"foreign",
"forall",
"if",
"import",
"in",
"infix",
"infixl",
"infixr",
"instance",
"let",
"module",
"newtype",
"of",
"then",
"type",
"where"
]