packages feed

tala-compiler-0.1.0: test/StaticMemberProjectTests.hs

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

{- | Project-boundary tests for static member binding across physical files.

The project compiler groups physical files by namespace before semantic
analysis. These tests ensure that a caller does not depend on input file order
and that a matching type in an unrelated namespace is not imported by spelling.
-}
module StaticMemberProjectTests (staticMemberProjectTests) where

import Data.List (sort)
import Progmasoft.Tala.AST
import Progmasoft.Tala.Compiler
import Progmasoft.Tala.Core
import Progmasoft.Tala.Diagnostic

staticMemberProjectTests :: [(String, IO Bool)]
staticMemberProjectTests =
    [ ("later physical file contributes a type to the namespace catalog", laterFileTypeIsVisible)
    , ("static overload binding is stable when project inputs are reversed", reversedInputsKeepSelection)
    , ("the project Core records separate identities for same-spelled overloads", projectOverloadsHaveUniqueIds)
    , ("the selected cross-file call targets the exact parameter type", crossFileCallSelectsExactSignature)
    , ("project Core assigns each overload to its actual source file", overloadSourceOwnersAreDistinct)
    , ("source file path spelling remains portable for overload owners", overloadOwnerPathsArePortable)
    , ("an unrelated namespace does not satisfy a type receiver", namespaceDoesNotLeakTypes)
    , ("an invalid unrelated namespace is still validated", unrelatedNamespaceStillValidated)
    , ("a type with the same simple name in two namespaces has separate identities", namespaceTypeSymbolsAreSeparate)
    , ("project input permutation preserves successful selection", inputPermutationKeepsSelectedSignature)
    , ("each namespace returns its own overloaded Core module", namespaceModulesKeepLocalFunctions)
    , ("the selected namespace exposes only its own source catalog", selectedNamespaceSourcesAreLocal)
    , ("source owner metadata names every overload exactly once", everyOverloadHasOneSourceOwner)
    , ("project compilation rejects a missing type before overload lookup", missingTypeIsNameResolutionError)
    , ("same namespace calls remain case-sensitive across files", crossFileMemberSpellingIsCaseSensitive)
    , ("cross-file wrong arity preserves the overload diagnostic", crossFileArityDiagnostic)
    , ("cross-file wrong type preserves the exact-match diagnostic", crossFileTypeDiagnostic)
    , ("cross-file instance methods are not callable through a type", crossFileInstanceMethodRejected)
    , ("cross-file private methods remain inaccessible", crossFilePrivateMethodRejected)
    , ("cross-file public candidates survive private sibling filtering", crossFilePublicCandidateSurvives)
    , ("source ownership stays stable with a namespace-only source file", namespaceOnlySourceKeepsOwners)
    , ("all project namespaces remain represented after successful compile", everyProjectNamespaceIsRetained)
    ]

laterFileTypeIsVisible :: IO Bool
laterFileTypeIsVisible = pure (projectCompiles [programSource, catalogSource])

reversedInputsKeepSelection :: IO Bool
reversedInputsKeepSelection = pure (projectCompiles [catalogSource, programSource])

projectOverloadsHaveUniqueIds :: IO Bool
projectOverloadsHaveUniqueIds = pure $ case compileProject [programSource, catalogSource] of
    Right project -> case namedFunctions (Identifier "Select") (projectEntryCore project) of
        [first, second] -> resolvedSymbol (coreFunctionName first) /= resolvedSymbol (coreFunctionName second)
        _ -> False
    Left _ -> False

crossFileCallSelectsExactSignature :: IO Bool
crossFileCallSelectsExactSignature = pure $ case compileProject [programSource, catalogSource] of
    Right project ->
        let moduleValue = projectEntryCore project
            expected =
                [ resolvedSymbol (coreFunctionName function)
                | function <- namedFunctionsWithParameter (Identifier "Select") intType moduleValue
                ]
            actual = [resolvedSymbol target | target <- coreCallTargets moduleValue, resolvedSpelling target == Identifier "Select"]
         in exactlyOne expected && exactlyOne actual && actual == expected
    Left _ -> False

overloadSourceOwnersAreDistinct :: IO Bool
overloadSourceOwnersAreDistinct = pure $ case compileProject [programSource, catalogSource] of
    Right project ->
        let moduleValue = projectEntryCore project
            selectedOwners =
                [ owner
                | function <- namedFunctions (Identifier "Select") moduleValue
                , let functionId = symbolIdValue (resolvedSymbol (coreFunctionName function))
                , (identity, owner) <- coreModuleFunctionSources moduleValue
                , identity == functionId
                ]
         in sort selectedOwners == ["Sources/Catalog.tala", "Sources/Catalog.tala"]
                && length selectedOwners == 2
    Left _ -> False

overloadOwnerPathsArePortable :: IO Bool
overloadOwnerPathsArePortable = pure $ case compileProject [programSource, catalogSource] of
    Right project ->
        let owners = [owner | (_, owner) <- coreModuleFunctionSources (projectEntryCore project)]
         in all (not . any (== '\\')) owners && all (not . null) owners
    Left _ -> False

namespaceDoesNotLeakTypes :: IO Bool
namespaceDoesNotLeakTypes = pure $ hasCode "TAN0001" $ compileProjectToCorePrep entry [programSource, libraryCatalogSource]

unrelatedNamespaceStillValidated :: IO Bool
unrelatedNamespaceStillValidated =
    pure $ hasCode "TAN0001" $ compileProjectToCorePrep entry [programSource, applicationCatalogSource, brokenLibrarySource]

namespaceTypeSymbolsAreSeparate :: IO Bool
namespaceTypeSymbolsAreSeparate = pure $ case compileProjectToCorePrep entry [programSource, applicationCatalogSource, libraryCatalogSource] of
    Right project ->
        let typeNames = concatMap namespaceCatalogNames (projectNamespaces project)
         in length (filter (== Identifier "Catalog") typeNames) == 2
    Left _ -> False

inputPermutationKeepsSelectedSignature :: IO Bool
inputPermutationKeepsSelectedSignature = pure (selectionFor [programSource, catalogSource] == selectionFor [catalogSource, programSource])

namespaceModulesKeepLocalFunctions :: IO Bool
namespaceModulesKeepLocalFunctions = pure $ case compileProjectToCorePrep entry [programSource, applicationCatalogSource, libraryCatalogSource] of
    Right project ->
        let modules = map (coreModuleFunctions . artifactCore . artifactFrontend) (projectNamespaces project)
            names = map (sort . map (resolvedSpelling . coreFunctionName)) modules
         in length modules == 2
                && names
                    == [ sort [Identifier "Evaluate", Identifier "Main", Identifier "Select", Identifier "Select"]
                       , [Identifier "Select"]
                       ]
    Left _ -> False

selectedNamespaceSourcesAreLocal :: IO Bool
selectedNamespaceSourcesAreLocal = pure $ case compileProjectToCorePrep entry [programSource, applicationCatalogSource, libraryCatalogSource] of
    Right project ->
        let selected = projectEntryNamespace project
         in sort (artifactSourceFiles selected) == ["Sources/ApplicationCatalog.tala", "Sources/Program.tala"]
                && sort (map snd (coreModuleFunctionSources (projectEntryCore project)))
                    == [ "Sources/ApplicationCatalog.tala"
                       , "Sources/ApplicationCatalog.tala"
                       , "Sources/Program.tala"
                       , "Sources/Program.tala"
                       ]
    Left _ -> False

everyOverloadHasOneSourceOwner :: IO Bool
everyOverloadHasOneSourceOwner = pure $ case compileProject [programSource, catalogSource] of
    Right project ->
        let moduleValue = projectEntryCore project
            overloadIds = map (symbolIdValue . resolvedSymbol . coreFunctionName) (namedFunctions (Identifier "Select") moduleValue)
            ownerIds = map fst (coreModuleFunctionSources moduleValue)
         in length overloadIds == 2
                && all (\identity -> length (filter (== identity) ownerIds) == 1) overloadIds
    Left _ -> False

missingTypeIsNameResolutionError :: IO Bool
missingTypeIsNameResolutionError = pure $ hasCode "TAN0001" $ compileProjectToCorePrep entry [programWithMissingCatalog]

crossFileMemberSpellingIsCaseSensitive :: IO Bool
crossFileMemberSpellingIsCaseSensitive = pure $ hasCode "TAT0029" $ compileProjectToCorePrep entry [programWithMember "select", catalogSource]

crossFileArityDiagnostic :: IO Bool
crossFileArityDiagnostic = pure $ hasCode "TAT0008" $ compileProjectToCorePrep entry [programWithCall "Catalog.Select()", catalogSource]

crossFileTypeDiagnostic :: IO Bool
crossFileTypeDiagnostic =
    pure $ hasCode "TAT0009" $ compileProjectToCorePrep entry [programWithCall "Catalog.Select(\"wrong\")", catalogSource]

crossFileInstanceMethodRejected :: IO Bool
crossFileInstanceMethodRejected = pure $ hasCode "TAT0031" $ compileProjectToCorePrep entry [programSource, instanceCatalogSource]

crossFilePrivateMethodRejected :: IO Bool
crossFilePrivateMethodRejected = pure $ hasCode "TAT0033" $ compileProjectToCorePrep entry [programSource, privateCatalogSource]

crossFilePublicCandidateSurvives :: IO Bool
crossFilePublicCandidateSurvives = pure $ projectCompiles [programSource, mixedAccessCatalogSource]

namespaceOnlySourceKeepsOwners :: IO Bool
namespaceOnlySourceKeepsOwners = pure $ case compileProject [programSource, catalogSource, namespaceOnlySource] of
    Right project ->
        let moduleValue = projectEntryCore project
            ownerPaths = map snd (coreModuleFunctionSources moduleValue)
         in length (filter (== "Sources/OnlyNamespace.tala") (coreModuleSourceFiles moduleValue)) == 1
                && "Sources/OnlyNamespace.tala" `notElem` ownerPaths
    Left _ -> False

everyProjectNamespaceIsRetained :: IO Bool
everyProjectNamespaceIsRetained = pure $ case compileProjectToCorePrep entry [programSource, applicationCatalogSource, libraryCatalogSource] of
    Right project -> sort (map artifactNamespace (projectNamespaces project)) == [Just entryNamespace, Just libraryNamespace]
    Left _ -> False

selectionFor :: [CompilerInput] -> Maybe Type
selectionFor inputs = do
    project <- either (const Nothing) Just (compileProjectToCorePrep entry inputs)
    let targets = [target | target <- coreCallTargets (projectEntryCore project), resolvedSpelling target == Identifier "Select"]
    target <- exactlyOneValue targets
    function <- findFunctionBySymbol target (coreModuleFunctions (projectEntryCore project))
    parameter <- exactlyOneValue (map snd (coreFunctionParameters function))
    pure parameter

projectCompiles :: [CompilerInput] -> Bool
projectCompiles = either (const False) (const True) . compileProjectToCorePrep entry

compileProject :: [CompilerInput] -> Either [Diagnostic] ProjectFrontendArtifacts
compileProject = compileProjectToCorePrep entry

findFunctionBySymbol :: ResolvedName -> [CoreFunction] -> Maybe CoreFunction
findFunctionBySymbol _ [] = Nothing
findFunctionBySymbol requested (function : remaining)
    | resolvedSymbol requested == resolvedSymbol (coreFunctionName function) = Just function
    | otherwise = findFunctionBySymbol requested remaining

namedFunctions :: Identifier -> CoreModule -> [CoreFunction]
namedFunctions spelling moduleValue =
    [ function
    | function <- coreModuleFunctions moduleValue
    , resolvedSpelling (coreFunctionName function) == spelling
    ]

namedFunctionsWithParameter :: Identifier -> Type -> CoreModule -> [CoreFunction]
namedFunctionsWithParameter spelling parameterType moduleValue =
    [ function
    | function <- namedFunctions spelling moduleValue
    , [(_, actualType)] <- [coreFunctionParameters function]
    , actualType == parameterType
    ]

coreCallTargets :: CoreModule -> [ResolvedName]
coreCallTargets moduleValue = concatMap (concatMap statementTargets . coreFunctionBody) (coreModuleFunctions moduleValue)

statementTargets :: CoreStatement -> [ResolvedName]
statementTargets statement = case statement of
    CoreBind binding -> expressionTargets (coreBindingValue binding)
    CoreAssign _ value -> expressionTargets value
    CoreReturn value -> expressionTargets value
    CoreIf condition yes no -> expressionTargets condition ++ concatMap statementTargets yes ++ concatMap statementTargets no
    CoreEvaluate value -> expressionTargets value
    CoreWhile condition body -> expressionTargets condition ++ concatMap statementTargets body
    CoreDoWhile body condition -> concatMap statementTargets body ++ expressionTargets condition
    CoreFor condition updates body -> expressionTargets condition ++ concatMap statementTargets updates ++ concatMap statementTargets body
    CoreBreak -> []
    CoreContinue -> []

expressionTargets :: CoreExpression -> [ResolvedName]
expressionTargets expression = case expression of
    CoreVariable {} -> []
    CoreLiteral {} -> []
    CoreApply callee arguments _ -> target callee ++ expressionTargets callee ++ concatMap expressionTargets arguments
    CorePrimitive _ arguments _ -> concatMap expressionTargets arguments
    CoreLet _ _ value body _ -> expressionTargets value ++ expressionTargets body
    CoreConditional condition whenTrue whenFalse _ -> concatMap expressionTargets [condition, whenTrue, whenFalse]
    CoreClosure _ _ _ body _ -> concatMap statementTargets body
    where
        target (CoreVariable name _) = [name]
        target _ = []

namespaceCatalogNames :: NamespaceArtifacts -> [Identifier]
namespaceCatalogNames artifacts =
    [ resolvedSpelling (declarationName declaration)
    | declaration <- syntaxDeclarations (typedSyntaxTree (artifactTypedAST (artifactFrontend artifacts)))
    , case declaration of TypeDeclaration {} -> True; TemplateTypeDeclaration {} -> True; _ -> False
    ]

programSource :: CompilerInput
programSource = programWithCall "Catalog.Select(value)"

programWithCall :: String -> CompilerInput
programWithCall call =
    CompilerInput
        "Sources/Program.tala"
        ( "namespace Application; class Program { public static int Evaluate(int value) { return "
            ++ call
            ++ "; } public static void Main() { return; } }"
        )

programWithMember :: String -> CompilerInput
programWithMember member = programWithCall ("Catalog." ++ member ++ "(value)")

programWithMissingCatalog :: CompilerInput
programWithMissingCatalog = programWithCall "Catalog.Select(value)"

applicationCatalogSource :: CompilerInput
applicationCatalogSource =
    CompilerInput
        "Sources/ApplicationCatalog.tala"
        "namespace Application; class Catalog { public static int Select(long value) { return 1; } public static int Select(int value) { return value; } }"

catalogSource :: CompilerInput
catalogSource =
    CompilerInput
        "Sources/Catalog.tala"
        "namespace Application; class Catalog { public static int Select(long value) { if (value > 0) { return 1; } else { return 2; } } public static int Select(int value) { if (value > 0) { return value; } else { return 3; } } }"

instanceCatalogSource :: CompilerInput
instanceCatalogSource =
    CompilerInput
        "Sources/Catalog.tala"
        "namespace Application; class Catalog { public int Select(int value) { return value; } }"

privateCatalogSource :: CompilerInput
privateCatalogSource =
    CompilerInput
        "Sources/Catalog.tala"
        "namespace Application; class Catalog { private static int Select(int value) { return value; } }"

mixedAccessCatalogSource :: CompilerInput
mixedAccessCatalogSource =
    CompilerInput
        "Sources/Catalog.tala"
        "namespace Application; class Catalog { private static int Select(String value) { return 1; } public static int Select(int value) { return value; } }"

libraryCatalogSource :: CompilerInput
libraryCatalogSource =
    CompilerInput
        "Sources/LibraryCatalog.tala"
        "namespace Library; class Catalog { public static int Select(int value) { return value; } }"

brokenLibrarySource :: CompilerInput
brokenLibrarySource =
    CompilerInput "Sources/Broken.tala" "namespace Library; class Broken { int Read() { return missing; } }"

namespaceOnlySource :: CompilerInput
namespaceOnlySource = CompilerInput "Sources/OnlyNamespace.tala" "namespace Application;"

entry :: QualifiedName
entry = QualifiedName [Identifier "Application", Identifier "Program"]

entryNamespace :: QualifiedName
entryNamespace = QualifiedName [Identifier "Application"]

libraryNamespace :: QualifiedName
libraryNamespace = QualifiedName [Identifier "Library"]

hasCode :: String -> Either [Diagnostic] value -> Bool
hasCode code result = case result of
    Left diagnostics -> any ((== code) . diagnosticCode) diagnostics
    Right _ -> False

exactlyOne :: [value] -> Bool
exactlyOne values = case values of
    [_] -> True
    _ -> False

exactlyOneValue :: [value] -> Maybe value
exactlyOneValue values = case values of
    [value] -> Just value
    _ -> Nothing