packages feed

tala-syntax-0.1.0: src/Progmasoft/Tala/FloatingLiteral.hs

-- SPDX-FileCopyrightText: 2026 Progmasoft <support@progmasoft.com>
-- SPDX-License-Identifier: MPL-2.0 WITH AdditionRef-Progmasoft-Exception-1.1

-- | Source-accurate floating-point literal scanning and validation.
module Progmasoft.Tala.FloatingLiteral
    ( FloatingLiteralError (..)
    , scanNumericCandidate
    , isFloatingCandidate
    , normalizeFloatingSpelling
    , validateFloatingSpelling
    , renderFloatingLiteralError
    ) where

import Data.Char (isAlphaNum, isDigit)

-- | Reason that a floating-point spelling is not valid in Tala.
data FloatingLiteralError
    = MissingFractionDigits
    | MissingExponentDigits
    | SeparatorOutsideDigitPair
    | SeparatorInsideExponent
    | MultipleDecimalPoints
    | MultipleExponents
    | InvalidFloatingCharacter Char
    | FloatingSuffixNotSupported
    deriving stock (Eq, Ord, Read, Show)

-- The sign is consumed only immediately after e/E. This prevents 1+2 from
-- becoming one malformed literal while retaining 2E-4 as one token.

{- | Consume one source-shaped numeric candidate and return the remaining text.
A sign belongs to the token only immediately after an exponent marker.
-}
scanNumericCandidate :: String -> (String, String)
scanNumericCandidate = go [] False
    where
        go output _ [] = (reverse output, [])
        go output afterExponent (character : remaining)
            | isAlphaNum character || character `elem` ['\'', '_', '.'] =
                go (character : output) (character `elem` ['e', 'E']) remaining
            | afterExponent && character `elem` ['+', '-'] = go (character : output) False remaining
            | otherwise = (reverse output, character : remaining)

{- | Decide whether a numeric candidate has a decimal or exponent marker.
Radix-prefixed integer candidates are deliberately excluded.
-}
isFloatingCandidate :: String -> Bool
isFloatingCandidate spelling
    | take 2 spelling `elem` ["0x", "0X", "0b", "0B", "0o", "0O"] = False
    | otherwise = any (`elem` spelling) ['.', 'e', 'E']

-- | Remove digit separators without otherwise changing the spelling.
normalizeFloatingSpelling :: String -> String
normalizeFloatingSpelling = filter (/= '\'')

-- | Validate a decimal floating spelling and return its normalized form.
validateFloatingSpelling :: String -> Either FloatingLiteralError String
validateFloatingSpelling spelling = do
    validateCharacters spelling
    validateCounts spelling
    validateSeparators spelling
    validateStructure spelling
    pure (normalizeFloatingSpelling spelling)

validateCharacters :: String -> Either FloatingLiteralError ()
validateCharacters = go
    where
        go [] = Right ()
        go (character : remaining)
            | isDigit character || character `elem` ['\'', '.', 'e', 'E', '+', '-'] = go remaining
            | isAlphaNum character || character == '_' = Left FloatingSuffixNotSupported
            | otherwise = Left (InvalidFloatingCharacter character)

validateCounts :: String -> Either FloatingLiteralError ()
validateCounts spelling
    | count '.' spelling > 1 = Left MultipleDecimalPoints
    | count 'e' lower > 1 = Left MultipleExponents
    | otherwise = Right ()
    where
        lower = map lowercaseExponent spelling
        lowercaseExponent 'E' = 'e'
        lowercaseExponent value = value

validateSeparators :: String -> Either FloatingLiteralError ()
validateSeparators spelling = go False spelling
    where
        go _ [] = Right ()
        go _ ('e' : remaining) = go True remaining
        go _ ('E' : remaining) = go True remaining
        go True ('\'' : _) = Left SeparatorInsideExponent
        go False (left : '\'' : right : remaining)
            | isDigit left && isDigit right = go False (right : remaining)
            | otherwise = Left SeparatorOutsideDigitPair
        go _ ('\'' : _) = Left SeparatorOutsideDigitPair
        go inExponent (_ : remaining) = go inExponent remaining

validateStructure :: String -> Either FloatingLiteralError ()
validateStructure spelling =
    let normalized = normalizeFloatingSpelling spelling
        (mantissa, exponentPart) = break (`elem` ['e', 'E']) normalized
     in do
            validateMantissa mantissa
            validateExponent exponentPart

validateMantissa :: String -> Either FloatingLiteralError ()
validateMantissa mantissa = case break (== '.') mantissa of
    (integerPart, [])
        | null integerPart || any (not . isDigit) integerPart -> Left MissingFractionDigits
        | otherwise -> Right ()
    (integerPart, _ : fractionPart)
        | null integerPart || any (not . isDigit) integerPart -> Left MissingFractionDigits
        | null fractionPart || any (not . isDigit) fractionPart -> Left MissingFractionDigits
        | otherwise -> Right ()

validateExponent :: String -> Either FloatingLiteralError ()
validateExponent [] = Right ()
validateExponent (_ : remaining) =
    let digits = case remaining of sign : rest | sign `elem` ['+', '-'] -> rest; _ -> remaining
     in if null digits || any (not . isDigit) digits then Left MissingExponentDigits else Right ()

-- | Render a floating-literal failure for a source diagnostic.
renderFloatingLiteralError :: FloatingLiteralError -> String
renderFloatingLiteralError issue = case issue of
    MissingFractionDigits -> "floating-point literal requires digits on both sides of the decimal point"
    MissingExponentDigits -> "floating-point exponent requires decimal digits"
    SeparatorOutsideDigitPair -> "apostrophe must appear between two digits of the floating-point mantissa"
    SeparatorInsideExponent -> "apostrophe is not allowed in a floating-point exponent"
    MultipleDecimalPoints -> "floating-point literal contains more than one decimal point"
    MultipleExponents -> "floating-point literal contains more than one exponent marker"
    InvalidFloatingCharacter character -> show character ++ " is not valid in a floating-point literal"
    FloatingSuffixNotSupported -> "floating-point literal suffixes are not supported"

count :: (Eq a) => a -> [a] -> Int
count value = length . filter (== value)