packages feed

tilia-0.0.1.0: tests/Tilia/Fixity/DebugSpec.hs

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

-- | The account a run gives of how it settled a module's operators.
module Tilia.Fixity.DebugSpec (spec) where

import Data.Choice (pattern Is)
import Data.Map.Strict qualified as Map
import Data.Text (Text)
import Data.Text qualified as T
import Test.Hspec
import Tilia.Fixity
  ( Direction (..),
    Fixity (..),
    Known (..),
    OpName (..),
    inBothNamespaces,
    nothingKnown,
    resolveScope,
  )
import Tilia.Fixity.Debug (fixityNotes, renderFixityNotes)
import Tilia.Palette (Palette (Plain))
import Tilia.Parser (defaultParserConfig, describeParseError, parseModule, pmModule)
import Tilia.Utils (lineWidth, visibleLength)

spec :: Spec
spec = do
  describe "what each import brought" $ do
    it "counts the operators a module was read for" $
      notesFor [("Prelude", Just []), ("Data.Map", Just [("!", infixl' 9)])] "import Data.Map\n"
        >>= (`shouldContain'` "· Data.Map: 1 operator")

    it "says so when a module could not be read" $
      notesFor [("Prelude", Just [])] "import Criterion.Main\n"
        >>= (`shouldContain'` "· Criterion.Main: could not be read")

    it "keeps the alias a qualified import goes under" $
      notesFor [("Prelude", Just []), ("Data.Map", Just [])] "import qualified Data.Map as M\n"
        >>= (`shouldContain'` "· Data.Map qualified as M: 0 operators")

    it "names the Prelude, which nobody wrote but everybody imports" $
      notesFor [("Prelude", Just [("+", infixl' 6)])] "f = 1\n"
        >>= (`shouldContain'` "· Prelude: 1 operator")

  describe "what became of each operator" $ do
    it "names the import that carried the fixity" $
      notesFor
        [("Prelude", Just []), ("Data.Map", Just [("!", infixl' 9)])]
        "import Data.Map\nf m = m ! 1\n"
        >>= (`shouldContain'` "· ! infixl 9, declared in Data.Map")

    it "says when the module declared it itself" $
      notesFor [("Prelude", Just [])] "infixr 5 <+>\nf a b = a <+> b\n"
        >>= (`shouldContain'` "· <+> infixr 5, declared in this module")

    it "says when nothing in scope declares it and everything was read" $
      notesFor [("Prelude", Just [])] "f a b = a <?> b\n"
        >>= (`mentions` "<?> infixl 9, the Report's default")

    it "says which unread module the answer might have been in" $
      notesFor [("Prelude", Just [])] "import Criterion.Main\nf a b = a <?> b\n"
        >>= ( `shouldContain'`
                "· <?> unknown: may be declared in Criterion.Main, which this run could not read"
            )

    it "counts the unread modules when there is more than one" $
      notesFor
        [("Prelude", Just [])]
        "import Criterion.Main\nimport Test.Tasty\nf a b = a <?> b\n"
        >>= ( `mentions`
                "may be declared in Criterion.Main or Test.Tasty, neither of which this run could read"
            )

    it "keeps the qualifier an operator was written under" $
      notesFor
        [("Prelude", Just []), ("Data.Map", Just [("!", infixl' 9)])]
        "import qualified Data.Map as M\nf m = m M.! 1\n"
        >>= (`shouldContain'` "· M.! infixl 9, declared in Data.Map")

    it "says when two imports disagree about one" $
      notesFor
        [ ("Prelude", Just []),
          ("Left", Just [("<+>", infixl' 6)]),
          ("Right", Just [("<+>", Fixity RightAssoc 5)])
        ]
        "import Left\nimport Right\nf a b = a <+> b\n"
        >>= (`mentions` "two modules in scope disagree about it")

    it "gives an operator one line however often it is written" $ do
      told <- notesFor [("Prelude", Just [])] "f a b c = a <?> b <?> c <?> a\n"
      length (filter (T.isInfixOf "<?>") told) `shouldBe` 1

  describe "how far reading an import got" $ do
    it "names the module that stopped it rather than the import above it" $
      throughHspec
        >>= ( `mentions`
                "may be declared in Test.Hspec → Test.Hspec.Core.Spec \
                \→ Test.QuickCheck.Property, which this run could not read"
            )

    it "says what an import that could not be read was reached through" $
      throughHspec
        >>= ( `mentions`
                "Test.Hspec: could not be read, through Test.Hspec.Core.Spec \
                \→ Test.QuickCheck.Property"
            )

    it "adds nothing for an import unread on its own account" $
      notesFor [("Prelude", Just [])] "import Criterion.Main\nf a b = a <?> b\n"
        >>= (`shouldContain'` "· Criterion.Main: could not be read")

  describe "the shape of it" $ do
    it "keeps every line it prints inside the width" $ do
      told <- throughHspec
      filter ((> lineWidth) . visibleLength) told `shouldBe` []

    it "sets a line it had to break further in than the entry it belongs to" $ do
      told <- throughHspec
      let indentOf = T.length . T.takeWhile (== ' ')
          opens l = "·" `T.isPrefixOf` T.stripStart l
      case break (T.isInfixOf "Test.QuickCheck.Property") told of
        (above, broken : _)
          | not (opens broken),
            (entry : _) <- filter opens (reverse above) ->
              indentOf broken `shouldSatisfy` (> indentOf entry)
        _ -> expectationFailure (show told)

    it "sets out under headings" $ do
      told <- notesFor [("Prelude", Just [("+", infixl' 6)])] "f a b = a + b\n"
      map T.stripStart told `shouldContain` ["· imports"]
      map T.stripStart told `shouldContain` ["· operators"]

    it "leaves out a heading it would have nothing to put under" $
      notesFor [("Prelude", Just [])] "f = 1\n"
        >>= (`shouldSatisfy` all ((/= "· operators") . T.stripStart))

    it "indents an entry further than the heading it sits under" $ do
      told <- notesFor [("Prelude", Just [])] "import Data.Map\n"
      let indentOf = T.length . T.takeWhile (== ' ')
          under heading = [indentOf l | l <- told, heading `T.isInfixOf` l]
      case (under "· imports", under "· Data.Map") of
        ([heading], [there]) -> there `shouldSatisfy` (> heading)
        (headings, entries) ->
          expectationFailure (show (headings, entries))

    it "says nothing about declarations a module does not make" $
      notesFor [("Prelude", Just [])] "f = 1\n"
        >>= (`shouldSatisfy` all (not . T.isInfixOf "declared here"))

    it "lists what the module declares for itself" $
      notesFor [("Prelude", Just [])] "infixr 5 <+>\nf a b = a <+> b\n"
        >>= (`shouldContain'` "· <+> infixr 5")

----------------------------------------------------------------------------
-- Helpers

-- | The account given of a module, against a world of imports that could be
-- read and imports that could not.
--
-- A module named in the world is readable and exports what is listed; a
-- module absent from it is one the resolver could not read at all.
notesFor :: [(Text, Maybe [(Text, Fixity)])] -> Text -> IO [Text]
notesFor = notesThrough []

-- | The same, told how far reading got below each import it could not read.
notesThrough ::
  -- | What lies below an import, ending at the module that stopped it
  [(Text, [Text])] ->
  [(Text, Maybe [(Text, Fixity)])] ->
  Text ->
  IO [Text]
notesThrough chains world source =
  renderFixityNotes Plain . Map.singleton "M.hs"
    <$> fixityNotes (Is #implicitPrelude) (pure . exportsOf) chainOf scope hsModule
  where
    scope =
      resolveScope
        (Is #implicitPrelude)
        nothingKnown {knownFixities = exportsOf, knownChain = chainFor}
        hsModule
    chainFor m = maybe [] id (lookup m chains)
    chainOf = pure . chainFor
    hsModule = pmModule parsed
    parsed = case parseModule defaultParserConfig "M.hs" ("module M where\n" <> source) of
      Left problem -> error (T.unpack (describeParseError problem))
      Right m -> m
    exportsOf m = do
      declared <- lookup m world
      inBothNamespaces . Map.fromList . map (\(op, fixity) -> (OpName op, fixity))
        <$> declared

infixl' :: Int -> Fixity
infixl' = Fixity LeftAssoc

-- | A module whose one import could not be read, and whose reading stopped
-- two modules further down. The real shape, and long enough to have to be
-- broken to fit the width.
throughHspec :: IO [Text]
throughHspec =
  notesThrough
    [("Test.Hspec", ["Test.Hspec.Core.Spec", "Test.QuickCheck.Property"])]
    [("Prelude", Just [])]
    "import Test.Hspec\nf a b = a <?> b\n"

-- | Is this line among them, whatever it was indented by?
shouldContain' :: [Text] -> Text -> Expectation
shouldContain' told wanted =
  map T.stripStart (rejoined told) `shouldContain` [wanted]

-- | Does some line say this much, whatever else it goes on to say?
mentions :: [Text] -> Text -> Expectation
mentions told wanted = rejoined told `shouldSatisfy` any (T.isInfixOf wanted)

-- | The entries as they read before they were broken to fit the width.
rejoined :: [Text] -> [Text]
rejoined = foldl add []
  where
    add seen l
      | null seen || "·" `T.isPrefixOf` T.stripStart l = seen <> [l]
      | otherwise = case unsnoc seen of
          Just (earlier, one) -> earlier <> [one <> " " <> T.stripStart l]
          Nothing -> [l]
    unsnoc xs = case reverse xs of
      [] -> Nothing
      (x : rest) -> Just (reverse rest, x)