packages feed

moonlight-core-0.1.0.0: test/facade/PublicSurfaceSpec.hs

{-# LANGUAGE CPP #-}

module PublicSurfaceSpec (tests) where

import Control.Exception (SomeException, evaluate, try)
import Control.Monad (void)
import Data.Char (isSpace)
import Data.List (isPrefixOf)
import Data.Text (pack)
import SourceShape (assertTopLevelDeclarationExcludesToken)
import Moonlight.Core (Refined, isQualifiedModuleName, refinedValue, spectralGap, splitModuleName)
import Moonlight.Core.Unsound (TrustJustification, unsafelyTrustRefined)
import System.FilePath (takeDirectory, (</>))
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertFailure, testCase, (@?=))

tests :: TestTree
tests =
  testGroup
    "PublicSurface"
    [ testCase "validated public tokens do not derive Read" testValidatedPublicTokensDoNotDeriveRead,
      testCase "module identifier splitting uses the Text segment owner" testModuleIdentifierTextSegments,
      testCase "spectralGap uses the two smallest sorted values" testSpectralGapUsesSortedValues,
      testCase "default public library exposes only Moonlight.Core" testDefaultPublicLibraryExposesOnlyCore,
      testCase "default public library reexports only Moonlight.Core.Unsound" testDefaultPublicLibraryReexportsOnlyUnsound,
      testCase "named public sublibraries expose the declared direct modules" testPublicNamedSublibrarySurface,
      testCase "egraph program sublibrary is public and canonically exposed" testEGraphProgramSublibrarySurface,
      testCase "unsafelyTrustRefined forces trust justification before minting Refined" testUnsafelyTrustRefinedForcesTrustJustification
    ]

data UnsafelyTrustRefinedProbe

testUnsafelyTrustRefinedForcesTrustJustification :: IO ()
testUnsafelyTrustRefinedForcesTrustJustification =
  assertIOFails $
    evaluate $
      refinedValue
        ( unsafelyTrustRefined
            (undefined :: TrustJustification)
            (42 :: Int) ::
            Refined UnsafelyTrustRefinedProbe Int
        )

testValidatedPublicTokensDoNotDeriveRead :: IO ()
testValidatedPublicTokensDoNotDeriveRead = do
  assertTopLevelDeclarationExcludesToken
    __FILE__
    "src-basis/Moonlight/Internal/Unsound.hs"
    "newtype Refined"
    "Read"
  assertTopLevelDeclarationExcludesToken
    __FILE__
    "src-basis/Moonlight/Internal/Unsound.hs"
    "newtype IdentifierToken"
    "Read"
  assertTopLevelDeclarationExcludesToken
    __FILE__
    "src-basis/Moonlight/Core/DomainId/Internal.hs"
    "newtype DomainId"
    "Read"

testModuleIdentifierTextSegments :: IO ()
testModuleIdentifierTextSegments = do
  splitModuleName (pack "Moonlight.Core.Test") @?= fmap pack ["Moonlight", "Core", "Test"]
  isQualifiedModuleName (pack "Moonlight.Core.Test") @?= True
  isQualifiedModuleName (pack "Moonlight..Core") @?= False

testSpectralGapUsesSortedValues :: IO ()
testSpectralGapUsesSortedValues =
  spectralGap [5 :: Int, 1, 3] @?= Just 2

testDefaultPublicLibraryExposesOnlyCore :: IO ()
testDefaultPublicLibraryExposesOnlyCore = do
  cabalText <- readFile (packageRootFromTestModule __FILE__ </> "moonlight-core.cabal")
  case defaultPublicLibraryExposedModules cabalText of
    Left parseFailure ->
      assertFailure parseFailure
    Right exposedModules ->
      exposedModules @?= ["Moonlight.Core"]

testDefaultPublicLibraryReexportsOnlyUnsound :: IO ()
testDefaultPublicLibraryReexportsOnlyUnsound = do
  cabalText <- readFile (packageRootFromTestModule __FILE__ </> "moonlight-core.cabal")
  case defaultPublicLibraryReexports cabalText of
    Left parseFailure ->
      assertFailure parseFailure
    Right reexports ->
      reexports @?= ["Moonlight.Core.Unsound"]

testPublicNamedSublibrarySurface :: IO ()
testPublicNamedSublibrarySurface = do
  cabalText <- readFile (packageRootFromTestModule __FILE__ </> "moonlight-core.cabal")
  publicNamedLibraryExposedModules cabalText
    @?= Right
      [ "Moonlight.Core.EGraph.Program",
        "Moonlight.Core.Guidance",
        "Moonlight.Core.Language",
        "Moonlight.Core.Pattern",
        "Moonlight.Core.Pattern.AntiUnify",
        "Moonlight.Core.Fix.Order",
        "Moonlight.Core.Site.Program",
        "Moonlight.Core.Substitution",
        "Moonlight.Core.Theory",
        "Moonlight.Automata.Pure.Algebra",
        "Moonlight.Automata.Pure.Coalgebra",
        "Moonlight.Automata.Pure.Core",
        "Moonlight.Automata.Pure.Transducer",
        "Moonlight.Core.Pattern.Automata",
        "Moonlight.Core.Pattern.Kernel"
      ]

testEGraphProgramSublibrarySurface :: IO ()
testEGraphProgramSublibrarySurface = do
  cabalText <- readFile (packageRootFromTestModule __FILE__ </> "moonlight-core.cabal")
  namedLibraryFieldValues "moonlight-core-egraph-program" "visibility" cabalText
    @?= Right ["public"]
  namedLibraryFieldValues "moonlight-core-egraph-program" "hs-source-dirs" cabalText
    @?= Right ["src-egraph-program"]
  namedLibraryFieldValues "moonlight-core-egraph-program" "exposed-modules" cabalText
    @?= Right ["Moonlight.Core.EGraph.Program"]

packageRootFromTestModule :: FilePath -> FilePath
packageRootFromTestModule testModulePath =
  takeDirectory $
    takeDirectory $
      takeDirectory $
        takeDirectory testModulePath

defaultPublicLibraryExposedModules :: String -> Either String [String]
defaultPublicLibraryExposedModules =
  defaultPublicLibraryFieldValues "exposed-modules"

defaultPublicLibraryReexports :: String -> Either String [String]
defaultPublicLibraryReexports =
  defaultPublicLibraryFieldValues "reexported-modules"

publicNamedLibraryExposedModules :: String -> Either String [String]
publicNamedLibraryExposedModules =
  fmap concat
    . traverse (fieldValues "exposed-modules")
    . filter isPublicNamedLibraryStanza
    . cabalStanzas
    . lines

isPublicNamedLibraryStanza :: [String] -> Bool
isPublicNamedLibraryStanza stanza =
  case stanza of
    stanzaHeader : _stanzaBody ->
      case words stanzaHeader of
        ["library", _libraryName] ->
          fieldValues "visibility" stanza == Right ["public"]
        _ -> False
    [] -> False

defaultPublicLibraryFieldValues :: String -> String -> Either String [String]
defaultPublicLibraryFieldValues fieldName cabalText =
  case filter isDefaultPublicLibraryStanza (cabalStanzas (lines cabalText)) of
    [] -> Left "expected to find the default library stanza with hs-source-dirs: src-public"
    [defaultLibraryStanza] -> fieldValues fieldName defaultLibraryStanza
    _multipleDefaultLibraries -> Left "expected exactly one default library stanza with hs-source-dirs: src-public"

cabalStanzas :: [String] -> [[String]]
cabalStanzas [] =
  []
cabalStanzas sourceLines =
  case dropWhile (not . isCabalStanzaHeader) sourceLines of
    [] -> []
    stanzaHeader : remainingLines ->
      let (stanzaBody, rest) = break isCabalStanzaHeader remainingLines
       in (stanzaHeader : stanzaBody) : cabalStanzas rest

isDefaultPublicLibraryStanza :: [String] -> Bool
isDefaultPublicLibraryStanza stanza =
  case stanza of
    stanzaHeader : stanzaBody ->
      trim stanzaHeader == "library" && any ((== "hs-source-dirs: src-public") . trim) stanzaBody
    [] -> False

namedLibraryFieldValues :: String -> String -> String -> Either String [String]
namedLibraryFieldValues libraryName fieldName cabalText =
  case filter (isNamedLibraryStanza libraryName) (cabalStanzas (lines cabalText)) of
    [] -> Left ("expected to find library stanza: " <> libraryName)
    [libraryStanza] -> fieldValues fieldName libraryStanza
    _multipleLibraries -> Left ("expected exactly one library stanza: " <> libraryName)

isNamedLibraryStanza :: String -> [String] -> Bool
isNamedLibraryStanza libraryName stanza =
  case stanza of
    stanzaHeader : _stanzaBody ->
      trim stanzaHeader == "library " <> libraryName
    [] -> False

fieldValues :: String -> [String] -> Either String [String]
fieldValues fieldName stanza =
  case dropWhile (not . isRequestedField) stanza of
    [] -> Left ("expected " <> fieldName <> " field in default library stanza")
    fieldLine : rest -> Right (fieldLineValues fieldLine <> continuationValues rest)
  where
    fieldPrefix = fieldName <> ":"
    isRequestedField line = fieldPrefix `isPrefixOf` trim line
    fieldLineValues line = fieldEntryValues (drop (length fieldPrefix) (trim line))
    continuationValues = fieldEntryValues . unlines . takeWhile (not . isCabalFieldStart)

fieldEntryValues :: String -> [String]
fieldEntryValues fieldText =
  filter (not . null) $
    fmap (trim . dropWhile (== ',') . trim) $
      lines fieldText

isCabalFieldStart :: String -> Bool
isCabalFieldStart sourceLine =
  case sourceLine of
    ' ' : ' ' : nextCharacter : _ -> nextCharacter /= ' ' && ':' `elem` sourceLine
    _ -> isCabalStanzaHeader sourceLine

isCabalStanzaHeader :: String -> Bool
isCabalStanzaHeader sourceLine =
  case words sourceLine of
    stanzaKind : _ | not (startsWithSpace sourceLine) -> stanzaKind `elem` cabalStanzaKinds
    _ -> False

cabalStanzaKinds :: [String]
cabalStanzaKinds =
  [ "benchmark",
    "common",
    "executable",
    "flag",
    "library",
    "test-suite"
  ]

startsWithSpace :: String -> Bool
startsWithSpace sourceLine =
  case sourceLine of
    leadingCharacter : _ -> isSpace leadingCharacter
    [] -> False

assertIOFails :: IO value -> IO ()
assertIOFails action = do
  result <- try (void action) :: IO (Either SomeException ())
  case result of
    Left _expectedFailure -> pure ()
    Right () -> assertFailure "expected IO action to fail"

trim :: String -> String
trim =
  trimRight . trimLeft

trimLeft :: String -> String
trimLeft =
  dropWhile isSpace

trimRight :: String -> String
trimRight =
  reverse . trimLeft . reverse