packages feed

tilia-0.1.1.0: tests/Tilia/Fixity/DependenciesSpec.hs

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedLabels #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE PatternSynonyms #-}
{-# LANGUAGE TupleSections #-}

-- | The fixity machinery, run over every dependency this project has.
--
-- "Tilia.Fixity.PlanSpec" checks the pipeline on a handful of modules
-- picked for what each one exercises. This checks it on all of them. Every
-- module of every package in this project's build plan is read out of the
-- package's source tarball and compared against what the compiler recorded
-- when it built that same package: two independent readings of one fact,
-- one by us and one by GHC.
module Tilia.Fixity.DependenciesSpec (spec) where

import Control.Monad (filterM)
import Data.ByteString qualified as BS
import Data.Choice (pattern Do, pattern Don't, pattern Is)
import Data.Foldable (for_)
import Data.List (isSuffixOf, sort)
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding qualified as T
import GHC.Hs (HsModule)
import GHC.Hs.Extension (GhcPs)
import System.Directory (doesDirectoryExist, doesFileExist, listDirectory)
import System.FilePath ((</>))
import Test.Hspec
import Tilia.BootFixities (bootFixities)
import Tilia.Cpp (blankCpp)
import Tilia.Fixity
import Tilia.Fixity.Cache (PlanToken (..), openCache)
import Tilia.Fixity.Interface (Interface (..), readInterface)
import Tilia.Fixity.PackageDb
import Tilia.Fixity.Plan
import Tilia.Gathered (gathered)
import Tilia.Parser
import Tilia.WithProjectPlan
  ( Dependency (..),
    compilerShipped,
    dependenciesOf,
    testsFor,
    withProjectPlan,
  )

spec :: Spec
spec = withProjectPlan withPlan

withPlan :: BuildPlan -> Spec
withPlan plan = do
  installed <- runIO readInstalledPackages
  fromSource <- runIO (fixitiesThrough <$> newResolverVia (Do #useCache) [FromSource] plan)
  fromInterface <- runIO (fixitiesThrough <$> newResolverVia (Do #useCache) [FromInterface] plan)
  resolver <- runIO (newResolver plan)
  let resolve = fixitiesThrough resolver
  own <- runIO ownModules
  let isShippedModule m = Map.member m bootFixities
  dependencies <- runIO (dependenciesOf (not . isShippedModule) plan installed)
  preloaded <- runIO (dependenciesOf isShippedModule plan installed)
  shippedPackages <- runIO compilerShipped
  let modules = concatMap depModules dependencies
      readFromSourcePackages =
        Set.fromList (fmap depPackage dependencies)
          `Set.difference` shippedPackages
  noCache <- runIO (openCache (Don't #useCache) (PlanToken ""))
  missing <-
    runIO $
      filterM (fmap not . doesFileExist . snd)
        . filter ((`Set.member` readFromSourcePackages) . ppName . fst)
        =<< plannedTarballs noCache plan
  describe "the tree this project is built against" $ do
    it "is a real dependency tree and not an empty plan" $
      length dependencies `shouldSatisfy` (>= 30)

    it "holds modules the compiler does not ship a fixity table for" $
      length modules `shouldSatisfy` (>= 500)

    -- What every comparison below reads one of its two sides out of. A
    -- source that is not here contradicts nothing, so the comparisons would
    -- pass without having compared anything: this is where that is caught,
    -- rather than in a hundred quietly hollow ticks.
    it "has the source of every package it reads" $
      case fmap (T.unpack . ppName . fst) missing of
        [] -> pure ()
        names ->
          expectationFailure $
            "no source for "
              <> unwords names
              <> "; the test suite fetches these itself, so something stopped it"

  describe "the operators the compiler ships with" $
    parallel $
      for_ (concatMap testsFor preloaded) $ \(label, chunk) ->
        it label $ do
          wrong <- traverse contradicts chunk
          concat wrong `shouldBe` []

  describe "every dependency declares what the compiler recorded" $
    parallel $
      for_ (concatMap testsFor dependencies) $ \(label, chunk) ->
        it label $ do
          wrong <- traverse (undeclared fromSource) chunk
          concat wrong `shouldBe` []

  describe "every dependency reads the same both ways" $
    parallel $
      for_ (concatMap testsFor dependencies) $ \(label, chunk) ->
        it label $ do
          wrong <- traverse (conflicting fromSource fromInterface . fst) chunk
          concat wrong `shouldBe` []

  describe "how much of the tree it reaches" $ do
    it "answers for every module of every dependency" $ do
      answers <- traverse (\(m, _) -> (m,) <$> resolve m) modules
      [m | (m, Nothing) <- answers] `shouldBe` []

    it "answers for every one of them out of the interfaces alone" $ do
      answers <- traverse (\(m, _) -> (m,) <$> fromInterface m) modules
      [m | (m, Nothing) <- answers] `shouldBe` []

    -- A loose floor on purpose. How much of the tree source alone reaches
    -- depends on the order the modules are asked for: a module in a
    -- re-export cycle is answered with what the cycle held when the chase
    -- reached it, and which module of the cycle gives way is whichever was
    -- entered first. Measured over ghc-lib-parser's 450 modules, sweeping
    -- them forwards, backwards and from twelve threads moved two of them
    -- either way, and over the whole tree, the boot packages read from
    -- source too, the figure has been seen at 93% on GHC 9.10. The check is
    -- here to catch the route collapsing, not to pin a number that is not
    -- pinned.
    it "reads most of them out of source alone" $ do
      answers <- traverse (fromSource . fst) modules
      let reached = length [() | Just _ <- answers]
      percent reached (length modules) `shouldSatisfy` (>= 70)

    it "finds the operators that are in it" $ do
      answers <- traverse (fromSource . fst) modules
      sum [Map.size fixities | Just fixities <- answers] `shouldSatisfy` (>= 300)

  describe "this project's own modules" $ do
    it "parses every one of them" $
      [path | (path, Nothing) <- own] `shouldBe` []

    it "resolves every module they import" $ do
      answers <- traverse (\m -> (m,) <$> resolve m) (importedByOwn own)
      [m | (m, Nothing) <- answers] `shouldBe` []

    it "settles every operator they use" $ do
      unsettled <- traverse (unsettledIn resolver) [(path, m) | (path, Just m) <- own]
      concat unsettled `shouldBe` []

----------------------------------------------------------------------------
-- The dependencies

-- | Where the boot table and the compiler both hold a fixity for an
-- operator and it is not the same fixity.
--
-- The table in "Tilia.BootFixities" was written by asking a GHC of one
-- version what its boot packages export. The tests run against whichever
-- GHC built the project, which this package supports three of. This is what
-- says the answer has not moved underneath the table.
contradicts :: (Text, FilePath) -> IO [String]
contradicts (modName, interfaceFile) =
  readInterface modName interfaceFile >>= \case
    Nothing -> pure []
    Just interface ->
      pure
        [ T.unpack modName
            <> ": "
            <> show op
            <> " is "
            <> show declared
            <> " per the compiler, "
            <> show ours
            <> " in the table"
        | (op, declared) <- Map.toList (interfaceDeclares interface),
          Just ours <- [Map.lookup op table],
          ours /= declared
        ]
  where
    table = Map.findWithDefault Map.empty modName bootFixities

-- | Every fixity the compiler recorded for a module that reading the
-- package's source did not produce.
undeclared ::
  -- | What a module declares, read from the package's source.
  (Text -> IO (Maybe (Fixities))) ->
  -- | The module, and the interface the compiler wrote for it.
  (Text, FilePath) ->
  IO [String]
undeclared fromSource (modName, interfaceFile) =
  readInterface modName interfaceFile >>= \case
    Nothing -> pure []
    Just interface ->
      fromSource modName >>= \case
        Nothing -> pure []
        Just fixities ->
          pure
            [ T.unpack modName
                <> ": "
                <> show op
                <> " is "
                <> show declared
                <> " per the compiler, "
                <> show (Map.lookup op fixities)
                <> " from source"
            | (op, declared) <- Map.toList (interfaceDeclares interface),
              writable declared,
              Map.lookup op fixities /= Just declared
            ]
  where
    -- GHC files @->@ under a module's fixities at precedence -1, below
    -- anything a source file is allowed to declare.
    writable f = fixityPrecedence f >= 0 && fixityPrecedence f <= 9

-- | Where the two routes both have an answer for an operator and it is not
-- the same answer.
--
-- Wider than 'undeclared', because a module's own declarations are the
-- smaller part of what it offers: most operators reach the module that
-- exports them through a chain of re-exports, and following that chain
-- through source text is the part of this most likely to go wrong. The
-- compiler followed the same chain when it built the package, so the two
-- have to arrive at the same place.
conflicting ::
  -- | The answer read out of the package's source.
  (Text -> IO (Maybe (Fixities))) ->
  -- | The answer read out of the compiler's interfaces.
  (Text -> IO (Maybe (Fixities))) ->
  Text ->
  IO [String]
conflicting fromSource fromInterface modName = do
  source <- fromSource modName
  compiled <- fromInterface modName
  pure $ case (source, compiled) of
    (Just a, Just b) ->
      [ T.unpack modName
          <> ": "
          <> show op
          <> " is "
          <> show fromText
          <> " from source, "
          <> show fromIface
          <> " from the interface"
      | (op, (fromText, fromIface)) <- Map.toList (Map.intersectionWith (,) a b),
        fromText /= fromIface
      ]
    _ -> []

percent :: Int -> Int -> Int
percent part whole = if whole == 0 then 0 else part * 100 `div` whole

----------------------------------------------------------------------------
-- This project

-- | Every Haskell file this project is made of, parsed.
--
-- 'Nothing' where one did not parse, which is a failure of its own rather
-- than something to skip over quietly.
ownModules :: IO [(FilePath, Maybe (HsModule GhcPs))]
ownModules = do
  paths <- concat <$> traverse haskellFilesIn ["src", "app", "tests"]
  traverse parsed paths
  where
    parsed path = do
      source <- T.decodeUtf8Lenient <$> BS.readFile path
      pure
        ( path,
          case parseModule defaultParserConfig path (blankCpp source) of
            Left _ -> Nothing
            Right pm -> Just (pmModule pm)
        )

haskellFilesIn :: FilePath -> IO [FilePath]
haskellFilesIn dir = do
  entries <- sort <$> listDirectory dir
  concat <$> traverse below entries
  where
    below entry = do
      let path = dir </> entry
      isDir <- doesDirectoryExist path
      if isDir
        then haskellFilesIn path
        else pure [path | ".hs" `isSuffixOf` path]

-- | What a module exports, where reading it settles every name.
fixitiesThrough :: Resolver -> Text -> IO (Maybe Fixities)
fixitiesThrough resolver modName = settled <$> askModule resolver modName
  where
    settled established
      | settlesEverything established = Just (establishedFixities established)
      | otherwise = Nothing

-- | Every module this project's own source imports.
importedByOwn :: [(FilePath, Maybe (HsModule GhcPs))] -> [Text]
importedByOwn own =
  Set.toList . Set.fromList $
    [ importModule i
    | (_, Just hsModule) <- own,
      i <- moduleImports (Is #implicitPrelude) (pure hsModule),
      not ("Paths_" `T.isPrefixOf` importModule i)
    ]

-- | The operators one of this project's modules uses that its imports,
-- resolved for real, cannot settle.
--
-- This is the whole machinery end to end: the plan is read, the packages
-- are found, their modules are read, the scope is assembled and the
-- operators are looked up in it. Anything left over is an operator this
-- project could not be laid out from.
unsettledIn ::
  Resolver ->
  (FilePath, HsModule GhcPs) ->
  IO [String]
unsettledIn resolver (path, hsModule) = do
  scope <- scopeFor resolver (Is #implicitPrelude) (pure hsModule)
  pure
    [ path <> ": " <> T.unpack (operatorSpelling qualifier op) <> " " <> show why
    | ((qualifier, op), why) <- unknownOperators scope (pure (gathered hsModule))
    ]