packages feed

tilia-0.1.2.0: tests/Tilia/EditorSpec.hs

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

-- | Formatting what an editor holds of a module.
module Tilia.EditorSpec (spec) where

import Data.ByteString qualified as BS
import Data.Choice (pattern Don't)
import Data.Map.Strict qualified as Map
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding qualified as T
import Data.Version (makeVersion)
import GHC.LanguageExtensions.Type (Extension (..))
import System.Directory (createDirectoryIfMissing)
import System.FilePath (takeDirectory, (</>))
import System.IO.Temp (withSystemTempDirectory)
import Test.Hspec
import Tilia.Editor
  ( Compiled (..),
    compiledSession,
    editorSession,
    formatBuffer,
  )
import Tilia.Fixity (Direction (..), Fixity (..), Namespace (..), OpName (..))
import Tilia.Fixity.HiFile (HiExport (..), HiFile (..), HiName (..))
import Tilia.Format (FormatError, formatErrorExitCode)
import Tilia.Run (Outcome (..))

spec :: Spec
spec = describe "formatting what an editor holds" $ do
  it "formats the buffer rather than the file it comes from" $
    withProject $ \buffer ->
      buffer ("src" </> "Ops.hs") "module Ops where\nx=1\n"
        `shouldReturn` CameTo "module Ops where\n\nx = 1\n"

  it "says nothing changed where nothing would" $
    withProject $ \buffer ->
      buffer ("src" </> "Ops.hs") "module Ops where\n\nx = 1\n"
        `shouldReturn` CameOut

  it "formats a module that is not on disk yet" $
    withProject $ \buffer ->
      buffer ("src" </> "New.hs") "module New where\nx=1\n"
        `shouldReturn` CameTo "module New where\n\nx = 1\n"

  it "keeps the line endings of the buffer" $
    withProject $ \buffer ->
      buffer ("src" </> "Ops.hs") "module Ops where\r\nx=1\r\n"
        `shouldReturn` CameTo "module Ops where\r\n\r\nx = 1\r\n"

  it "keeps the byte order mark the buffer begins with" $
    withProject $ \buffer ->
      buffer ("src" </> "Ops.hs") "\xFEFFmodule Ops where\nx=1\n"
        `shouldReturn` CameTo "\xFEFFmodule Ops where\n\nx = 1\n"

  it "takes fixities from the modules the buffer imports" $
    withProject $ \buffer ->
      buffer ("src" </> "Uses.hs") "module Uses where\nimport Ops\nx=a <+> b\n"
        `shouldReturn` CameTo "module Uses where\n\nimport Ops\n\nx = a <+> b\n"

  it "takes fixities from a module that begins with a byte order mark" $
    withProject $ \buffer ->
      buffer ("src" </> "Uses.hs") "module Uses where\nimport Marked\nx=a <-> b\n"
        `shouldReturn` CameTo "module Uses where\n\nimport Marked\n\nx = a <-> b\n"

  it "declines an operator from a module it cannot read" $
    withProject $ \buffer ->
      buffer ("src" </> "Uses.hs") "module Uses where\nimport Nowhere\nx=a <?> b\n"
        `shouldReturn` CameDeclined 15

  it "takes fixities for a buffer whose branches do not parse together" $
    withProject $ \buffer ->
      buffer ("src" </> "Checks.hs") alternatives `shouldReturn` CameOut

  it "declines an operator it cannot settle where the branches do not parse together" $
    withProject $ \buffer ->
      buffer ("src" </> "Uses.hs") alternativesUsingUnknown
        `shouldReturn` CameDeclined 15

  it "leaves alone a file the project's .tiliaignore excludes" $
    withProject $ \buffer ->
      buffer ("src" </> "Generated.hs") "module Generated where\nx=1\n"
        `shouldReturn` CameOut

  it "fails on a buffer that does not parse" $
    withProject $ \buffer ->
      buffer ("src" </> "Ops.hs") "module Ops where\nx = (\n"
        `shouldReturn` CameFailed 4

  it "fails where no project is above the file" $
    withSystemTempDirectory "tilia-editor" $ \dir ->
      either (Just . failure) (const Nothing)
        <$> editorSession
          (dir </> "A.hs")
          Nothing
          (Don't #useCache)
          (Don't #download)
          (Don't #checkAst)
          (Don't #checkIdempotence)
          (Don't #debugFixity)
        `shouldReturn` Just 2

  describe "given what compiling the project establishes" $ do
    it "takes fixities from the interfaces it is given" $
      withCompiled $ \buffer ->
        buffer ("src" </> "Uses.hs") "module Uses where\nimport Ops\nx=a <+> b\n"
          `shouldReturn` CameTo "module Uses where\n\nimport Ops\n\nx = a <+> b\n"

    it "declines an operator from a module it is given no interface of" $
      withCompiled $ \buffer ->
        buffer ("src" </> "Uses.hs") "module Uses where\nimport Nowhere\nx=a <?> b\n"
          `shouldReturn` CameDeclined 15

    it "parses with the extensions it is given rather than the package's" $
      withCompiled $ \buffer ->
        buffer ("src" </> "Uses.hs") "module Uses where\nf = \\case\n    _ -> 1\n"
          `shouldReturn` CameTo "module Uses where\n\nf = \\case\n  _ -> 1\n"

    it "leaves out the branches the versions it is given rule out" $
      withCompiled $ \buffer ->
        buffer ("src" </> "Uses.hs") versioned `shouldReturn` CameOut

-- | A formatted module with a conditional around two alternatives, which do
-- not parse one after the other.
alternatives :: Text
alternatives =
  T.unlines
    [ "{-# LANGUAGE CPP #-}",
      "",
      "module Checks where",
      "",
      "g :: IO ()",
      "g =",
      "#ifdef CHECK",
      "  id $",
      "    do",
      "      print 1",
      "#else",
      "  do",
      "    print 1",
      "#endif",
      "",
      "h :: Int -> String",
      "h n =",
      "  error $",
      "    \"count: \" ++ show n"
    ]

-- | The same kind of alternatives, using an operator from a module that
-- cannot be read.
alternativesUsingUnknown :: Text
alternativesUsingUnknown =
  T.unlines
    [ "{-# LANGUAGE CPP #-}",
      "module Uses where",
      "import Nowhere",
      "x =",
      "#ifdef CHECK",
      "  id $ do",
      "    a <?> b",
      "#else",
      "  do",
      "    a <?> b",
      "#endif"
    ]

-- | A module with a branch for a version of the package other than the one
-- built, which uses an operator from a module only that version has.
versioned :: Text
versioned =
  T.unlines
    [ "{-# LANGUAGE CPP #-}",
      "",
      "module Uses where",
      "",
      "#if MIN_VERSION_fake(1,0,0)",
      "import Nowhere",
      "",
      "x = a <?> b",
      "#else",
      "import Ops",
      "",
      "x = a <+> b",
      "#endif"
    ]

-- | What formatting a buffer came to, with errors told apart by the status
-- they exit with.
data Came
  = -- | Nothing to change.
    CameOut
  | -- | Formatted into this.
    CameTo Text
  | -- | Declined.
    CameDeclined Int
  | -- | Failed.
    CameFailed Int
  deriving (Eq, Show)

-- | Tell what became of a buffer.
came :: Outcome -> Came
came = \case
  Unchanged -> CameOut
  Changed _ formatted -> CameTo formatted
  Declined e -> CameDeclined (failure e)
  Failed e -> CameFailed (failure e)

-- | The status a failure exits with.
failure :: FormatError -> Int
failure = formatErrorExitCode

-- | A project with a module declaring @infixl 6 <+>@, one beginning with a
-- byte order mark declaring @infixr 0 <->@, a @.tiliaignore@
-- excluding @src/Generated.hs@, and a plan for it trusted as up to date,
-- handed to the test as a way to format a buffer as one of its files.
withProject :: ((FilePath -> Text -> IO Came) -> Expectation) -> Expectation
withProject act =
  inProject $ \dir project -> do
    let planFile = dir </> "elsewhere" </> "plan.json"
    write planFile $
      "{\"compiler-id\":\"ghc-0.0\",\"install-plan\":[\
      \{\"pkg-name\":\"fake\",\"pkg-version\":\"0.1.0.0\",\
      \\"pkg-src\":{\"type\":\"local\",\"path\":\"./.\"}}]}"
    act $ \file text -> do
      let path = project </> file
      editorSession
        path
        (Just planFile)
        (Don't #useCache)
        (Don't #download)
        (Don't #checkAst)
        (Don't #checkIdempotence)
        (Don't #debugFixity)
        >>= \case
          Left e -> pure (CameFailed (failure e))
          Right session -> came <$> formatBuffer session path text

-- | The project of 'withProject', with what compiling it would establish
-- given instead of a plan: @LambdaCase@ in force, version 0.1.0.0 of the
-- package, and the interfaces of @Ops@ and of an empty @Prelude@.
withCompiled :: ((FilePath -> Text -> IO Came) -> Expectation) -> Expectation
withCompiled act =
  inProject $ \_ project ->
    act $ \file text -> do
      let path = project </> file
      compiledSession path compiled (Don't #checkAst) (Don't #checkIdempotence)
        >>= \case
          Left e -> pure (CameFailed (failure e))
          Right session -> came <$> formatBuffer session path text
  where
    compiled =
      Compiled
        { compiledExtensions = [ImplicitPrelude, LambdaCase],
          compiledWith = makeVersion [9, 14, 1],
          compiledPackages = Map.singleton "fake" (makeVersion [0, 1, 0, 0]),
          compiledInterface = pure . (`Map.lookup` interfaces)
        }
    interfaces =
      Map.fromList
        [ ( "Ops",
            HiFile
              { hiModule = "Ops",
                hiExports = [Avail (HiName "Ops" InTerms (OpName "<+>"))],
                hiFixities = [(InTerms, OpName "<+>", Fixity LeftAssoc 6)]
              }
          ),
          ("Prelude", HiFile{hiModule = "Prelude", hiExports = [], hiFixities = []})
        ]

-- | A project with a module declaring @infixl 6 <+>@, one beginning with a
-- byte order mark declaring @infixr 0 <->@, and a @.tiliaignore@ excluding
-- @src/Generated.hs@, given to the test with the directory it is in.
inProject :: (FilePath -> FilePath -> Expectation) -> Expectation
inProject act =
  withSystemTempDirectory "tilia-editor" $ \dir -> do
    let project = dir </> "project"
    write (project </> "fake.cabal") $
      T.unlines
        [ "cabal-version: 2.4",
          "name: fake",
          "version: 0.1.0.0",
          "library",
          "  exposed-modules: Ops, Marked",
          "  hs-source-dirs: src",
          "  default-language: Haskell2010"
        ]
    write (project </> "src" </> "Ops.hs") $
      T.unlines
        [ "module Ops ((<+>)) where",
          "infixl 6 <+>",
          "(<+>) :: a -> a -> a",
          "(<+>) = const"
        ]
    write (project </> "src" </> "Marked.hs") $
      T.unlines
        [ "\xFEFFmodule Marked ((<->)) where",
          "infixr 0 <->",
          "(<->) :: a -> a -> a",
          "(<->) = const"
        ]
    write (project </> ".tiliaignore") "src/Generated.hs\n"
    act dir project

-- | Write a file, with the directories it is in.
write :: FilePath -> Text -> IO ()
write path text = do
  createDirectoryIfMissing True (takeDirectory path)
  BS.writeFile path (T.encodeUtf8 text)