packages feed

tala-compiler-0.1.0: test/FormatSweepTests.hs

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

{- | Every conversion that can be written, against the rules stated a second time.

"RuntimeCallTests" holds formats chosen by hand. This module writes all of
them: every set of flags, with and without a width and a precision, each
written as a number and as a star, for every conversion letter. What the
reader of formats makes of each is compared with a prediction that is
written here from the rules alone and shares nothing with the reader:

* a conversion takes the flags of its row and no other;
* @-@ excludes @0@, and @+@ excludes the space;
* a precision is taken by @%f@ and @%s@;
* @%n@ and @%%@ take nothing at all.

The same sweep is then run through the reference text functions, where the
properties of a field hold whatever the conversion: it is never shorter than
its width, padding is all it adds, and a value at the left is followed by
spaces only.
-}
module FormatSweepTests (formatSweepTests) where

import Data.Char (isDigit)
import Data.List (isPrefixOf, isSuffixOf, subsequences)
import RuntimeText qualified as Reference
import Progmasoft.Tala.RuntimeCall
import Progmasoft.Tala.TypeChecker.Format

formatSweepTests :: [(String, Bool)]
formatSweepTests =
    [ ("every way to write %" ++ [letter] ++ " is read as the rules say", all agrees (written letter))
    | letter <- letters
    ]
        ++ [ ("the sweep writes every combination", length (concatMap written letters) == 9 * 64 * 3 * 3)
           , ("the sweep holds formats that are accepted", any ((== Accepted) . predicted) (concatMap written letters))
           , ("the sweep holds formats that are rejected", any ((== Rejected) . predicted) (concatMap written letters))
           , ("a letter outside the grammar is never a conversion", all (unknown . (: [])) (filter (`notElem` letters ++ "AO") ['a' .. 'z'] ++ ['B' .. 'N']))
           , ("an integer field is never shorter than its width", and [length (integerField value) >= maybe 0 id width | value@(_, width, _) <- integerCases])
           , ("an integer field without a width is the number itself", and [integerField (flags, Nothing, value) == unpadded flags value | (flags, _, value) <- integerCases])
           , ("padding is all a width adds to an integer", all paddingOnly integerCases)
           , ("a value at the left is followed by spaces only", all leftAligned integerCases)
           , ("zeros stand after the sign", all zerosAfterSign integerCases)
           , ("grouping adds apostrophes and nothing else", all groupingOnly [value | (_, _, value) <- integerCases])
           , ("groups have three digits, the first at most three", all groupsOfThree [value | (_, _, value) <- integerCases])
           , ("hexadecimal digits are those of the magnitude", all hexadecimalDigits [value | (_, _, value) <- integerCases])
           , ("a text field is never shorter than its width", and [length (textField width Nothing value) >= width | width <- [0 .. 9], value <- texts])
           , ("a precision is the most characters a text field keeps", and [textField 0 (Just precision) value == take precision value | precision <- [0 .. 9], value <- texts])
           , ("a fixed-point field has its precision's digits after the point", all fractionDigits fixedCases)
           , ("a fixed-point number with no digits after the point has no point", and ['.' `notElem` fixed 0 value | value <- rationals])
           , ("more digits never change the digits before them by more than rounding", all prefixStable rationals)
           ]
    where
        letters = "duxfscbn%"

-- ------------------------------------------------------------ the reader

-- | What the rules say of a format.
data Verdict = Accepted | Rejected
    deriving (Eq, Show)

-- | One conversion as it is written: flags, width, precision and letter.
type Written = (String, String, String, Char)

-- | Every way to write a conversion with the given letter.
written :: Char -> [Written]
written letter =
    [ (flags, width, precision, letter)
    | flags <- subsequences "-0+ #'"
    , width <- ["", "7", "*"]
    , precision <- ["", ".3", ".*"]
    ]

spelled :: Written -> String
spelled (flags, width, precision, letter) = "%" ++ flags ++ width ++ precision ++ [letter]

-- | The rules, from the specification, without the reader.
predicted :: Written -> Verdict
predicted (flags, width, precision, letter)
    | letter `elem` "n%" = if null flags && null width && null precision then Accepted else Rejected
    | any (`notElem` taken) flags = Rejected
    | '-' `elem` flags && '0' `elem` flags = Rejected
    | '+' `elem` flags && ' ' `elem` flags = Rejected
    | not (null precision) && letter `notElem` "fs" = Rejected
    | otherwise = Accepted
    where
        taken = case letter of
            'd' -> "-0+ '"
            'u' -> "-0'"
            'x' -> "-0#"
            'f' -> "-0+ '"
            _ -> "-"

{- | Whether the reader agrees with the rules, and, for an accepted
conversion, reads what was written: the flags as their bits, and one
argument more for each star.
-}
agrees :: Written -> Bool
agrees value@(flags, width, precision, letter) = case (predicted value, parseFormat (spelled value)) of
    (Rejected, Left problem) -> formatProblemCode problem == "TAT0076"
    (Accepted, Right [ConversionPiece conversion]) ->
        letter `notElem` "n%"
            && conversionLetter (conversionKind conversion) == letter
            && conversionFlagBits conversion == sum (map bit flags) + (if letter == 'x' then flagHexadecimal else 0)
            && conversionArgumentCount conversion == 1 + length (filter (== "*") [width, drop 1 precision])
            && conversionWidth conversion == size width
            && conversionPrecision conversion == size (drop 1 precision)
    (Accepted, Right [NewlinePiece]) -> letter == 'n'
    (Accepted, Right [LiteralPiece "%"]) -> letter == '%'
    _ -> False
    where
        bit flag = case flag of
            '-' -> flagLeft
            '0' -> flagZero
            '+' -> flagPlus
            ' ' -> flagSpace
            '#' -> flagAlternate
            _ -> flagGroup
        size text
            | null text = NoSize
            | text == "*" = ArgumentSize
            | otherwise = FixedSize (read text)

unknown :: String -> Bool
unknown letter = case parseFormat ('%' : letter) of
    Left problem -> formatProblemCode problem == "TAT0075"
    Right _ -> False

-- --------------------------------------------------- the reference fields

-- | Flags, a width when there is one, and a value.
type IntegerCase = (Integer, Maybe Int, Integer)

integerCases :: [IntegerCase]
integerCases =
    [ (flags, width, value)
    | flags <- [0, flagLeft, flagZero, flagPlus, flagSpace, flagGroup, flagPlus + flagZero, flagGroup + flagLeft, flagHexadecimal, flagHexadecimal + flagAlternate, flagHexadecimal + flagZero]
    , width <- [Nothing, Just 0, Just 1, Just 5, Just 12, Just 30]
    , value <- values
    ]
    where
        values =
            [0, 1, -1, 9, 10, -10, 99, 100, 999, 1000, -1000, 12345, 123456, 1234567, -1234567]
                ++ [2 ^ (63 :: Int) - 1, negate (2 ^ (63 :: Int)), 2 ^ (64 :: Int) - 1]

fieldOf :: Integer -> Maybe Int -> Reference.Conversion
fieldOf flags width = Reference.Conversion flags (maybe absent toInteger width) absent

integerField :: IntegerCase -> String
integerField (flags, width, value) = Reference.formatInteger (fieldOf flags width) value

unpadded :: Integer -> Integer -> String
unpadded flags = Reference.formatInteger (fieldOf flags Nothing)

has :: Integer -> Integer -> Bool
has flags flag = (flags `div` flag) `mod` 2 == 1

-- | A field is the number without a width, with padding and nothing else.
paddingOnly :: IntegerCase -> Bool
paddingOnly value@(flags, width, number) =
    length field == max (maybe 0 id width) (length plain)
        && filter (`notElem` " 0") plain `isSubsequence` field
        && length field >= maybe 0 id width
    where
        field = integerField value
        plain = unpadded flags number
        isSubsequence [] _ = True
        isSubsequence _ [] = False
        isSubsequence (x : xs) (y : ys)
            | x == y = isSubsequence xs ys
            | otherwise = isSubsequence (x : xs) ys

leftAligned :: IntegerCase -> Bool
leftAligned value@(flags, _, number)
    | has flags flagLeft = plain `isPrefixOf` field && all (== ' ') (drop (length plain) field)
    | otherwise = True
    where
        field = integerField value
        plain = unpadded flags number

-- | With the zero flag the field ends in the digits and begins with the sign.
zerosAfterSign :: IntegerCase -> Bool
zerosAfterSign value@(flags, _, number)
    | has flags flagZero && not (has flags flagLeft) =
        ' ' `notElem` dropWhile (== ' ') (drop (length sign) field)
            && sign `isPrefixOf` field
            && digits `isSuffixOf` field
    | otherwise = True
    where
        field = integerField value
        plain = unpadded flags number
        (sign, digits) = span (`elem` "+- ") plain

groupingOnly :: Integer -> Bool
groupingOnly value = filter (/= '\'') (unpadded flagGroup value) == unpadded 0 value

groupsOfThree :: Integer -> Bool
groupsOfThree value = case groups (dropWhile (== '-') (unpadded flagGroup value)) of
    first : rest -> not (null first) && length first <= 3 && all ((== 3) . length) rest && all (all isDigit) (first : rest)
    [] -> False
    where
        groups text = case break (== '\'') text of
            (group, _ : more) -> group : groups more
            (group, []) -> [group]

hexadecimalDigits :: Integer -> Bool
hexadecimalDigits value =
    all (`elem` "0123456789abcdef") digits
        && foldl (\total digit -> total * 16 + toInteger (position digit)) 0 digits == abs value
        && (value < 0) == ("-" `isPrefixOf` field)
    where
        field = unpadded flagHexadecimal value
        digits = dropWhile (== '-') field
        position digit = length (takeWhile (/= digit) "0123456789abcdef")

texts :: [String]
texts = ["", "a", "abc", "abcdefghij", "\233\8364"]

textField :: Int -> Maybe Int -> String -> String
textField width precision = Reference.formatText (Reference.Conversion 0 (toInteger width) (maybe absent toInteger precision))

rationals :: [Rational]
rationals = [0, 1, 1 / 2, 1 / 4, 1 / 8, 3 / 8, 5 / 2, 7 / 2, 12345 / 8, 1 / 1024, 999999 / 1000, 1234567 / 2]

fixed :: Integer -> Rational -> String
fixed precision = Reference.formatFloating (Reference.Conversion 0 absent precision) False

fixedCases :: [(Integer, Rational)]
fixedCases = [(precision, value) | precision <- [1 .. 12], value <- rationals]

fractionDigits :: (Integer, Rational) -> Bool
fractionDigits (precision, value) = case break (== '.') (fixed precision value) of
    (whole, _ : fraction) -> not (null whole) && all isDigit whole && length fraction == fromInteger precision && all isDigit fraction
    _ -> False

{- | With a precision large enough to hold every digit of the value, a
larger precision only appends zeros. These values are sums of powers of two
with at most ten binary places, or thousandths, so twelve digits hold the
binary ones exactly.
-}
prefixStable :: Rational -> Bool
prefixStable value
    | exact = fixed 20 value == fixed 12 value ++ replicate 8 '0'
    | otherwise = True
    where
        exact = fromRational (value * 1024) == (fromInteger (round (value * 1024)) :: Rational)