packages feed

aihc-parser-1.0.0.2: test/Test/Properties/Arb/Identifiers.hs

{-# LANGUAGE OverloadedStrings #-}

-- | Shared generators and helpers used by Expr.hs, Pattern.hs, Type.hs, and Decl.hs.
-- This module breaks import cycles because it doesn't depend on any of those modules.
module Test.Properties.Arb.Identifiers
  ( -- * Variable identifiers
    genVarId,
    genVarIdNoHash,
    genVarUnqualifiedName,
    genVarUnqualifiedNameNoHash,
    genVarName,
    shrinkIdent,
    isValidGeneratedIdent,
    genVarSym,
    isValidGeneratedVarSym,
    shrinkVarSym,

    -- * Constructor identifiers
    genConId,
    genConUnqualifiedName,
    genConName,
    shrinkConIdent,
    isValidConIdent,

    -- * Constructor operator symbols
    genConSym,
    isValidGeneratedConSym,
    shrinkConSym,

    -- * Name shrinkers
    shrinkName,
    shrinkUnqualifiedName,

    -- * Module qualifiers
    genOptionalQualifier,
    genModuleQualifier,
    genModuleSegment,
    shrinkQualifier,

    -- * Field names
    genFieldName,

    -- * Quasi-quotation helpers
    genQuoterName,
    isValidQuoterName,
    genQuasiBody,

    -- * Character and string generators
    genCharValue,
    genStringValue,

    -- * Numeric helpers
    genTenths,
    showHex,
    shrinkFloat,
  )
where

import Aihc.Parser.Syntax (Name (..), NameType (..), UnqualifiedName (..), allKnownExtensions, mkUnqualifiedName, qualifyName)
import Aihc.Parser.Token (LexToken (..), LexTokenKind (..), lexTokensWithExtensions)
import Data.Char (GeneralCategory (..), generalCategory)
import Data.Maybe (isJust)
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as T
import Test.QuickCheck (Gen, chooseInt, chooseInteger, elements, oneof, shrink, shrinkIntegral, shrinkList, vectorOf)

allChars :: [Char]
allChars = [minBound .. maxBound]

varIdentStartChars :: [Char]
varIdentStartChars = filter isValidGeneratedIdentStartChar allChars

conIdentStartChars :: [Char]
conIdentStartChars = filter isValidConIdentStartChar allChars

varIdentTailChars :: [Char]
varIdentTailChars = filter isValidGeneratedIdentTailChar allChars

conIdentTailChars :: [Char]
conIdentTailChars = filter isValidConIdentTailChar allChars

symbolChars :: [Char]
symbolChars = filter isValidSymbolChar allChars

-- Symbols starting with ':' are constructors, symbols starting with '|' collide
-- with guard/alternative syntax in expression positions.
varSymStartChars :: [Char]
varSymStartChars = filter (\c -> c /= ':' && c /= '|') symbolChars

reservedOperators :: Set.Set Text
reservedOperators =
  Set.fromList
    [ "..",
      ":",
      "::",
      "=",
      "\\",
      "|",
      "<-",
      "->",
      "~",
      "=>",
      "-<",
      ">-",
      "-<<",
      ">>-",
      "→",
      "←",
      "⇒",
      "∷",
      "∀",
      "⤙",
      "⤚",
      "⤛",
      "⤜",
      "⦇",
      "⦈",
      "⟦",
      "⟧",
      "⊸",
      "★"
    ]

-------------------------------------------------------------------------------
-- Variable identifiers
-------------------------------------------------------------------------------

genVarId :: Gen Text
genVarId = do
  first <- elements varIdentStartChars
  restLen <- chooseInt (0, 8)
  rest <- vectorOf restLen (elements varIdentTailChars)
  hashCount <- chooseInt (0, 4)
  let candidate = T.pack (first : rest) <> T.replicate hashCount "#"
  if isValidGeneratedIdent candidate
    then pure candidate
    else genVarId

genVarIdNoHash :: Gen Text
genVarIdNoHash = do
  first <- elements varIdentStartChars
  restLen <- chooseInt (0, 8)
  rest <- vectorOf restLen (elements varIdentTailChars)
  let candidate = T.pack (first : rest)
  if isValidGeneratedIdent candidate
    then pure candidate
    else genVarIdNoHash

genVarUnqualifiedName :: Gen UnqualifiedName
genVarUnqualifiedName = oneof [mkUnqualifiedName NameVarId <$> genVarId, mkUnqualifiedName NameVarSym <$> genVarSym]

-- For silly reasons, as-patterns don't support magic hashes in var-ids, but it's fine in var-syms.
genVarUnqualifiedNameNoHash :: Gen UnqualifiedName
genVarUnqualifiedNameNoHash = oneof [mkUnqualifiedName NameVarId <$> genVarIdNoHash, mkUnqualifiedName NameVarSym <$> genVarSym]

genVarName :: Gen Name
genVarName = do
  qual <- genOptionalQualifier
  qualifyName qual <$> genVarUnqualifiedName

shrinkIdent :: Text -> [Text]
shrinkIdent "a" = []
shrinkIdent ident = "a" : shrinkWithPreservedFirstChar isValidGeneratedIdent ident

isValidGeneratedIdent :: Text -> Bool
isValidGeneratedIdent ident =
  case unsnocMagicHash ident of
    Just (baseIdent, _magicHashes) ->
      case T.uncons baseIdent of
        Just (first, rest) ->
          baseIdent /= "_"
            && isValidGeneratedIdentStartChar first
            && T.all isValidGeneratedIdentTailChar rest
            && lexesAsVariableIdentifier ident
        Nothing -> False
    Nothing -> False

lexesAsVariableIdentifier :: Text -> Bool
lexesAsVariableIdentifier ident =
  case lexTokensWithExtensions allKnownExtensions ident of
    [LexToken {lexTokenKind = TkVarId tokIdent}, LexToken {lexTokenKind = TkEOF}] -> tokIdent == ident
    _ -> False

unsnocMagicHash :: Text -> Maybe (Text, Text)
unsnocMagicHash ident =
  let magicHashes = T.takeWhileEnd (== '#') ident
      baseIdent = T.dropEnd (T.length magicHashes) ident
   in if T.null ident || T.null baseIdent then Nothing else Just (baseIdent, magicHashes)

-------------------------------------------------------------------------------
-- Constructor identifiers (uppercase-starting names)
-------------------------------------------------------------------------------

-- | Generate a constructor/type constructor name starting with uppercase.
-- Produces names like @Foo@, @A1@, @T'x@, etc.
genConId :: Gen Text
genConId = do
  first <- elements conIdentStartChars
  restLen <- chooseInt (0, 5)
  rest <- vectorOf restLen (elements conIdentTailChars)
  hashCount <- chooseInt (0, 4)
  pure (T.pack (first : rest) <> T.replicate hashCount "#")

genConUnqualifiedName :: Gen UnqualifiedName
genConUnqualifiedName = oneof [mkUnqualifiedName NameConId <$> genConId, mkUnqualifiedName NameConSym <$> genConSym]

genConName :: Gen Name
genConName = do
  qual <- genOptionalQualifier
  qualifyName qual <$> genConUnqualifiedName

shrinkConIdent :: Text -> [Text]
shrinkConIdent ident =
  filter (\candidate -> candidate /= ident && isValidConIdent candidate) $
    ["C"]
      <> [asciiConIdent ident | T.any (> '\x7f') ident]
      <> shrinkWithPreservedFirstChar isValidConIdent ident
  where
    asciiConIdent name =
      case T.uncons name of
        Just (_, rest) -> "A" <> T.map replaceTailUnicode rest
        Nothing -> "A"
    replaceTailUnicode c = if c > '\x7f' then 'a' else c

isValidConIdent :: Text -> Bool
isValidConIdent ident =
  case unsnocMagicHash ident of
    Just (baseIdent, _magicHashes) ->
      case T.uncons baseIdent of
        Just (first, rest) ->
          isValidConIdentStartChar first
            && T.all isValidConIdentTailChar rest
        Nothing -> False
    Nothing -> False

-------------------------------------------------------------------------------
-- Constructor operator symbols
-------------------------------------------------------------------------------

-- | Generate a constructor operator symbol (starting with @:@).
-- Examples: @:+@, @:*@, @:==@, @:+:@
-- Rejects @:@ (built-in list cons) and @::@ (type signature operator).
genConSym :: Gen Text
genConSym = do
  restLen <- chooseInt (1, 3)
  rest <- vectorOf restLen (elements symbolChars)
  let op = T.pack (':' : rest)
  if isValidGeneratedConSym op then pure op else genConSym

isValidGeneratedConSym :: Text -> Bool
isValidGeneratedConSym op =
  case T.uncons op of
    Just (':', rest) -> not (T.null rest) && T.all isValidSymbolChar rest && op `Set.notMember` reservedOperators
    _ -> False

genVarSym :: Gen Text
genVarSym = do
  first <- elements varSymStartChars
  restLen <- chooseInt (0, 3)
  rest <- vectorOf restLen (elements symbolChars)
  let op = T.pack (first : rest)
  if isValidGeneratedVarSym op then pure op else genVarSym

isValidGeneratedVarSym :: Text -> Bool
isValidGeneratedVarSym op =
  case T.uncons op of
    Just (first, rest) ->
      first /= ':'
        && first /= '|'
        && isValidSymbolChar first
        && T.all (/= '`') op
        && T.all isValidSymbolChar rest
        && op `Set.notMember` reservedOperators
        && not (isDashRun op)
        && not (isOverloadedLabelPrefix op)
    Nothing -> False

-- | '#' followed by an identifier-start char is an overloaded label, not a symbol.
isOverloadedLabelPrefix :: Text -> Bool
isOverloadedLabelPrefix op =
  case T.uncons op of
    Just ('#', rest) -> case T.uncons rest of
      Just (c, _) -> isValidGeneratedIdentStartChar c || isValidConIdentStartChar c
      Nothing -> False
    _ -> False

-------------------------------------------------------------------------------
-- Module qualifiers
-------------------------------------------------------------------------------

-- | Generate an optional module qualifier (e.g., Nothing or Just "Data.List").
-- Biased towards Nothing to keep most names unqualified.
genOptionalQualifier :: Gen (Maybe Text)
genOptionalQualifier =
  oneof
    [ pure Nothing,
      Just <$> genModuleQualifier
    ]

-- | Generate a module qualifier like "Data.List" or "Prelude".
genModuleQualifier :: Gen Text
genModuleQualifier = do
  segCount <- chooseInt (1, 3)
  segs <- vectorOf segCount genModuleSegment
  pure (T.intercalate "." segs)

-- | Generate a single module name segment (starts with uppercase). Contrary to conids, module segments may not contain magic hashes.
genModuleSegment :: Gen Text
genModuleSegment = T.filter (/= '#') <$> genConId

-------------------------------------------------------------------------------
-- Field names
-------------------------------------------------------------------------------

-- | Generate a record field name (lowercase-starting identifier).
-- Rejects reserved identifiers and extension-reserved identifiers.
genFieldName :: Gen Text
genFieldName = do
  first <- elements (['a' .. 'z'] <> ['_'])
  restLen <- chooseInt (0, 5)
  rest <- vectorOf restLen (elements (['a' .. 'z'] <> ['A' .. 'Z'] <> ['0' .. '9'] <> "_'"))
  let candidate = T.pack (first : rest)
  if lexesAsVariableIdentifier candidate
    then pure candidate
    else genFieldName

-------------------------------------------------------------------------------
-- Quasi-quotation helpers
-------------------------------------------------------------------------------

-- | Generate a quasi-quoter name, excluding TH bracket names (e, d, p, t).
genQuoterName :: Gen Text
genQuoterName = do
  first <- elements (['a' .. 'z'] <> ['_'])
  restLen <- chooseInt (0, 4)
  rest <- vectorOf restLen (elements (['a' .. 'z'] <> ['A' .. 'Z'] <> ['0' .. '9'] <> "_'"))
  let candidate = T.pack (first : rest)
  if isValidQuoterName candidate
    then pure candidate
    else genQuoterName

isValidQuoterName :: Text -> Bool
isValidQuoterName name =
  case T.uncons name of
    Just (first, rest) ->
      (first `elem` (['a' .. 'z'] <> ['_']))
        && T.all (`elem` (['a' .. 'z'] <> ['A' .. 'Z'] <> ['0' .. '9'] <> "_'.")) rest
        -- Exclude names that clash with TH quote brackets
        && name `notElem` ["e", "t", "d", "p"]
    Nothing -> False

-- | Generate a quasi-quotation body (safe characters only).
genQuasiBody :: Gen Text
genQuasiBody = do
  len <- chooseInt (0, 10)
  chars <- vectorOf len (elements (['a' .. 'z'] <> ['A' .. 'Z'] <> ['0' .. '9'] <> " +-*/_()"))
  pure (T.pack chars)

-------------------------------------------------------------------------------
-- Character and string generators
-------------------------------------------------------------------------------

-- | Generate a printable character safe for use in literals.
genCharValue :: Gen Char
genCharValue = elements (['a' .. 'z'] <> ['A' .. 'Z'] <> ['0' .. '9'] <> " _")

-- | Generate a string value for use in string literals.
genStringValue :: Gen Text
genStringValue = do
  len <- chooseInt (0, 8)
  T.pack <$> vectorOf len genCharValue

-------------------------------------------------------------------------------
-- Numeric helpers
-------------------------------------------------------------------------------

-- | Generate a decimal value with one decimal digit of precision.
genTenths :: Gen Rational
genTenths = do
  whole <- chooseInteger (0, 99)
  frac <- chooseInteger (0, 9)
  pure (fromInteger whole + fromInteger frac / 10)

-- | Show an integer as a hexadecimal string (without prefix).
showHex :: Integer -> String
showHex value
  | value < 16 = [hexDigit value]
  | otherwise = showHex (value `div` 16) <> [hexDigit (value `mod` 16)]
  where
    hexDigit x = "0123456789abcdef" !! fromInteger x

-- | Shrink a floating-point value, maintaining one decimal digit of precision.
shrinkFloat :: Rational -> [Rational]
shrinkFloat value =
  [fromInteger shrunk / 10 | shrunk <- shrinkIntegral (round (value * 10) :: Integer), shrunk >= 0]

isValidGeneratedIdentStartChar :: Char -> Bool
isValidGeneratedIdentStartChar c = c == '_' || generalCategory c == LowercaseLetter

isValidConIdentStartChar :: Char -> Bool
isValidConIdentStartChar c = generalCategory c `elem` [UppercaseLetter, TitlecaseLetter]

isValidIdentNumberChar :: Char -> Bool
isValidIdentNumberChar c =
  case generalCategory c of
    DecimalNumber -> True
    OtherNumber -> True
    _ -> False

isValidGeneratedIdentTailChar :: Char -> Bool
isValidGeneratedIdentTailChar c = c == '\'' || isValidGeneratedIdentStartChar c || isValidConIdentStartChar c || isValidIdentContinueChar c

isValidConIdentTailChar :: Char -> Bool
isValidConIdentTailChar c = c == '\'' || isValidGeneratedIdentStartChar c || isValidConIdentStartChar c || isValidConIdentContinueChar c

isValidIdentContinueChar :: Char -> Bool
isValidIdentContinueChar c =
  case generalCategory c of
    LetterNumber -> True
    ModifierLetter -> True
    _ -> isValidIdentNumberChar c

isValidConIdentContinueChar :: Char -> Bool
isValidConIdentContinueChar c =
  case generalCategory c of
    DecimalNumber -> True
    ModifierLetter -> True
    NonSpacingMark -> True
    _ -> False

isValidSymbolChar :: Char -> Bool
isValidSymbolChar c = c `elem` (":!#$%&*+./<=>?@\\^|-~" :: String) || isValidUnicodeSymbolChar c && c /= '`'

isValidUnicodeSymbolChar :: Char -> Bool
isValidUnicodeSymbolChar c =
  case generalCategory c of
    MathSymbol -> True
    CurrencySymbol -> True
    ModifierSymbol -> True
    OtherSymbol -> True
    ConnectorPunctuation -> c > '\x7f'
    DashPunctuation -> c > '\x7f'
    OtherPunctuation -> c > '\x7f'
    _ -> False

isDashRun :: Text -> Bool
isDashRun op = T.length op >= 2 && T.all (== '-') op

shrinkWithPreservedFirstChar :: (Text -> Bool) -> Text -> [Text]
shrinkWithPreservedFirstChar isValid name =
  case T.uncons name of
    Just (first, rest) ->
      [ candidate
      | shrunkRest <- shrink (T.unpack rest),
        let candidate = T.pack (first : shrunkRest),
        isValid candidate
      ]
    Nothing -> []

-- | Shrink a variable symbol, preferring shorter and ASCII-only operators.
-- Always tries @+@ as the minimal candidate, replaces unicode chars with @+@,
-- and tries shorter prefixes.
shrinkVarSym :: Text -> [Text]
shrinkVarSym sym =
  filter (\s -> s /= sym && isValidGeneratedVarSym s) $
    ["+"]
      <> [T.map replaceUnicode sym | T.any (> '\x7f') sym]
      <> [T.take n sym | n <- [1 .. T.length sym - 1]]
  where
    replaceUnicode c = if c > '\x7f' then '+' else c

-- | Shrink a constructor symbol, preferring shorter and ASCII-only operators.
-- Always tries @:+@ as the minimal candidate, replaces unicode chars in the
-- tail with @+@, and tries shorter prefixes.
shrinkConSym :: Text -> [Text]
shrinkConSym sym =
  filter (\s -> s /= sym && isValidGeneratedConSym s) $
    [":+"]
      <> [ T.cons ':' (T.map (\c -> if c > '\x7f' then '+' else c) rest)
         | Just (':', rest) <- [T.uncons sym],
           T.any (> '\x7f') rest
         ]
      <> [T.take n sym | n <- [1 .. T.length sym - 1]]

shrinkNameText :: NameType -> Text -> [Text]
shrinkNameText nt txt = case nt of
  NameVarId -> shrinkIdent txt
  NameConId -> shrinkConIdent txt
  NameVarSym -> shrinkVarSym txt
  NameConSym -> shrinkConSym txt

-- | Shrink a module segment: try "A" as the minimal, replace unicode with
-- ASCII where possible, then try dropping tail characters.
shrinkModuleSegment :: Text -> [Text]
shrinkModuleSegment "A" = []
shrinkModuleSegment seg =
  filter (\s -> s /= seg && isValidConIdent s) $
    ["A"]
      <> [T.map (\c -> if c > '\x7f' then 'A' else c) seg | T.any (> '\x7f') seg]
      <> shrinkWithPreservedFirstChar isValidConIdent seg

-- | Shrink a module qualifier by trying fewer or shorter segments.
shrinkQualifier :: Text -> [Text]
shrinkQualifier q =
  let segs = T.splitOn "." q
   in filter (/= q) $
        ["A"]
          <> [T.intercalate "." segs' | segs' <- shrinkList shrinkModuleSegment segs, not (null segs')]

-- | Shrink a 'Name': try removing the module qualifier, shrink the qualifier
-- itself, then shrink the local name text (replacing unicode symbols with ASCII where possible).
shrinkName :: Name -> [Name]
shrinkName name =
  [name {nameQualifier = Nothing} | isJust (nameQualifier name)]
    <> [name {nameQualifier = Just q'} | Just q <- [nameQualifier name], q' <- shrinkQualifier q]
    <> [name {nameText = t} | t <- shrinkNameText (nameType name) (nameText name)]

shrinkUnqualifiedName :: UnqualifiedName -> [UnqualifiedName]
shrinkUnqualifiedName name =
  [name {unqualifiedNameText = t} | t <- shrinkNameText (unqualifiedNameType name) (unqualifiedNameText name)]