Utils.hs 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398
  1. module Utils (module Utils,
  2. AgdaError(..)) where
  3. import Control.Applicative
  4. import Control.Arrow ((&&&))
  5. import Control.Exception (finally)
  6. import Control.Monad
  7. import Data.Array
  8. import Data.Bifunctor
  9. import qualified Data.ByteString as BS
  10. import Data.Char
  11. import Data.List (intercalate, sortBy)
  12. import Data.Maybe
  13. import Data.Map (Map)
  14. import qualified Data.Map as Map
  15. import Data.Ord
  16. import Data.Text (Text)
  17. import qualified Data.Text as T
  18. import Data.Text.Encoding
  19. import System.Directory
  20. import System.Environment
  21. import System.Exit
  22. import System.FilePath
  23. import qualified System.FilePath.Find as Find
  24. import System.FilePath.GlobPattern
  25. import System.IO.Temp
  26. import System.PosixCompat.Time ( epochTime )
  27. import System.PosixCompat.Files ( modificationTime, touchFile )
  28. import System.Process ( proc, CreateProcess(..) )
  29. import qualified System.Process.Text as PT
  30. import Test.Tasty ( TestName, TestTree )
  31. import Test.Tasty.Silver
  32. import Test.Tasty.Silver.Advanced ( GDiff(..), pattern ShowText, goldenTest1, readFileMaybe )
  33. import qualified Text.Regex.TDFA as R
  34. import qualified Text.Regex.TDFA.Text as RT ( compile )
  35. import Agda.Interaction.ExitCode (AgdaError(..), agdaErrorFromInt)
  36. import Agda.Utils.Maybe
  37. import Agda.Utils.Environment
  38. import qualified Agda.Version (package)
  39. data ProgramResult = ProgramResult
  40. { exitCode :: ExitCode
  41. , stdOut :: Text
  42. , stdErr :: Text
  43. } deriving (Read, Show, Eq)
  44. fromProgramResult :: ProgramResult -> (ExitCode, Text, Text)
  45. fromProgramResult (ProgramResult c o e) = (c, o, e)
  46. toProgramResult :: (ExitCode, Text, Text) -> ProgramResult
  47. toProgramResult (c, o, e) = ProgramResult c o e
  48. printProgramResult :: ProgramResult -> Text
  49. printProgramResult = printProcResult . fromProgramResult
  50. type AgdaArgs = [String]
  51. readAgdaProcessWithExitCode :: AgdaArgs -> Text
  52. -> IO (ExitCode, Text, Text)
  53. readAgdaProcessWithExitCode args inp = do
  54. agdaBin <- getAgdaBin
  55. envArgs <- getEnvAgdaArgs
  56. let agdaProc = (proc agdaBin (envArgs ++ args)) { create_group = True }
  57. PT.readCreateProcessWithExitCode agdaProc inp
  58. data AgdaResult
  59. = AgdaSuccess (Maybe Text) -- ^ A success can come with warnings
  60. | AgdaFailure Int (Maybe AgdaError) -- ^ A failure, with exit code
  61. runAgdaWithOptions
  62. :: String -- ^ test name
  63. -> AgdaArgs -- ^ options (including the name of the input file)
  64. -> Maybe FilePath -- ^ file containing additional options and flags
  65. -> Maybe FilePath -- ^ file containing additional environment variables
  66. -> IO (ProgramResult, AgdaResult)
  67. runAgdaWithOptions testName opts mflag mvars = do
  68. flags <- case mflag of
  69. Nothing -> pure []
  70. Just flagFile -> maybe [] T.unpack <$> readTextFileMaybe flagFile
  71. -- setting the additional environment variables, saving a backup
  72. backup <- case mvars of
  73. Nothing -> pure []
  74. Just varFile -> do
  75. addEnv <- maybe [] (map parseEntry . lines . T.unpack) <$> readTextFileMaybe varFile
  76. backup <- if null addEnv then pure [] else do
  77. env <- getEnvironment
  78. pure $ map (\ (var, _) -> (var, fromMaybe "" $ lookup var env)) addEnv
  79. forM_ addEnv $ \ (var, val) -> do
  80. setEnv var =<< expandEnvironmentVariables val
  81. pure backup
  82. let agdaArgs = opts ++ words flags
  83. let runAgda = \ extraArgs -> let args = agdaArgs ++ extraArgs in
  84. readAgdaProcessWithExitCode args T.empty
  85. (ret, stdOut, stdErr) <- do
  86. if "--compile" `elem` agdaArgs
  87. -- Andreas, 2017-04-14, issue #2317
  88. -- Create temporary files in system temp directory.
  89. -- This has the advantage that upon Ctrl-C no junk is left behind
  90. -- in the Agda directory.
  91. then withSystemTempDirectory ("MAZ_compile_" ++ testName)
  92. (\ compDir -> runAgda ["--compile-dir=" ++ compDir])
  93. else runAgda []
  94. -- reinstating the old environment
  95. `finally` mapM_ (uncurry setEnv) backup
  96. cleanedStdOut <- cleanOutput stdOut
  97. cleanedStdErr <- cleanOutput stdErr
  98. let res = ProgramResult ret cleanedStdOut cleanedStdErr
  99. pure $ (res,) $ case ret of
  100. ExitSuccess -> AgdaSuccess $ filterMaybe hasWarning cleanedStdOut
  101. ExitFailure code -> AgdaFailure code (agdaErrorFromInt code)
  102. where
  103. -- parse "x=e" into ("x","e") and "x" into ("x", "")
  104. parseEntry :: String -> (String, String)
  105. parseEntry = second (drop 1) . break (== '=')
  106. -- Andreas, 2020-09-22: according to the documentation of getEnvironment,
  107. -- a missing '=' might mean to set the variable to the empty string.
  108. hasWarning :: Text -> Bool
  109. hasWarning t =
  110. "———— All done; warnings encountered ————————————————————————"
  111. `T.isInfixOf` t
  112. getEnvAgdaArgs :: IO AgdaArgs
  113. getEnvAgdaArgs = maybe [] words <$> getEnvVar "AGDA_ARGS"
  114. getAgdaBin :: IO FilePath
  115. getAgdaBin = getProg "agda"
  116. -- | Gets the program executable. If an environment variable
  117. -- YYY_BIN is defined (with yyy converted to upper case),
  118. -- the value of it is returned. Otherwise, the input value
  119. -- is returned unchanged.
  120. getProg :: String -> IO FilePath
  121. getProg prog = fromMaybe prog <$> getEnvVar (map toUpper prog ++ "_BIN")
  122. getEnvVar :: String -> IO (Maybe String)
  123. getEnvVar v =
  124. lookup v <$> getEnvironment
  125. -- | List of possible extensions of agda files.
  126. agdaExtensions :: [String]
  127. agdaExtensions =
  128. [ ".agda"
  129. , ".lagda"
  130. , ".lagda.tex"
  131. , ".lagda.rst"
  132. , ".lagda.md"
  133. , ".lagda.org"
  134. ]
  135. -- | List of files paired with agda files by the test suites.
  136. -- E.g. files recording the accepted output or error message.
  137. helperExtensions :: [String]
  138. helperExtensions =
  139. [ ".flags" -- Supplementary file
  140. , ".warn" -- Produced by test/Succeed
  141. , ".err" -- Produced by test/Fail
  142. , ".in", ".out" -- For running test/interaction
  143. ]
  144. -- | Generalizes 'stripExtension'.
  145. stripAnyOfExtensions :: [String] -> FilePath -> Maybe FilePath
  146. stripAnyOfExtensions exts p = listToMaybe $ catMaybes $ map (`stripExtension` p) exts
  147. stripAgdaExtension :: FilePath -> Maybe FilePath
  148. stripAgdaExtension = stripAnyOfExtensions agdaExtensions
  149. stripHelperExtension :: FilePath -> Maybe FilePath
  150. stripHelperExtension = stripAnyOfExtensions helperExtensions
  151. -- | Checks if a String has Agda extension
  152. hasAgdaExtension :: FilePath -> Bool
  153. hasAgdaExtension = isJust . stripAgdaExtension
  154. dropAgdaExtension :: FilePath -> FilePath
  155. dropAgdaExtension p =
  156. fromMaybe (error $ "Utils.hs: Path " ++ p ++ " does not have an Agda extension") $
  157. stripAgdaExtension p
  158. dropAgdaOrOtherExtension :: FilePath -> FilePath
  159. dropAgdaOrOtherExtension = fromMaybe <$> dropExtension <*> stripAgdaExtension
  160. data SearchMode = Rec | NonRec deriving (Eq)
  161. -- | Find (non)recursively all Agda files in the given directory
  162. -- and order them alphabetically, with the exception that
  163. -- the ones from the last week come first, ordered by age (youngest first).
  164. -- This heuristic should run the interesting tests first.
  165. -- The age computation also considers helper file modification time
  166. -- (.err, .in, .out, .warn).
  167. getAgdaFilesInDir :: SearchMode -> FilePath -> IO [FilePath]
  168. getAgdaFilesInDir recurse dir = do
  169. -- Get all agda files...
  170. agdaFiles <- findWithInfo recP (hasAgdaExtension <$> Find.filePath) dir
  171. -- ...and organize them in a map @baseName ↦ (modificationTime, baseName.ext)@.
  172. -- We may assume that all agda files have different @baseName@s.
  173. -- (Otherwise agda will complain when trying to load them.)
  174. let m = Map.fromList $ flip map agdaFiles $
  175. {-key-} (dropAgdaExtension . Find.infoPath) &&&
  176. {-val-} (modificationTime . Find.infoStatus) &&& Find.infoPath
  177. -- Andreas, 2020-06-08, issue #4736: Go again through all the files.
  178. -- If we find one whose baseName is in the map and
  179. -- that has a newer modificationTime than what is stored in the map,
  180. -- we update the modificationTime in the map.
  181. m <- Find.fold recP (flip updateModificationTime) m dir
  182. -- Andreas, 2017-04-29 issue #2546
  183. -- We take first the new test cases, then the rest.
  184. -- Both groups are ordered alphabetically,
  185. -- but for the first group, the younger ones come first.
  186. -- Ignore first (i.e., the time) component if older than @consideredNew@.
  187. -- The second component is the filepath.
  188. now <- epochTime
  189. let order = comparing $ first $ \ t -> let age = now - t in
  190. Down $ -- This Down reverses the usual order Nothing < Just
  191. if age > consideredNew then Nothing else Just $
  192. Down age -- This Down reverses the effect of the first Down
  193. return $ map snd $ sortBy order $ Map.elems m
  194. where
  195. -- If @recurse /= Rec@ don't go into subdirectories
  196. recP = pure (recurse == Rec) Find.||? Find.depth Find.<? 1
  197. -- Updating the map of agda files to take modification
  198. -- of secondary files (.err, .in, .out) into account.
  199. updateModificationTime f =
  200. case stripHelperExtension $ Find.infoPath f of
  201. Just k -> Map.adjust (updIfNewer $ modificationTime $ Find.infoStatus f) k
  202. Nothing -> id
  203. updIfNewer tNew old@(tOld, f)
  204. | tNew > tOld = (tNew, f)
  205. | otherwise = old
  206. -- Test cases from up to one week ago are considered new.
  207. consideredNew = 7 * 24 * 60 * 60
  208. -- | Search a directory recursively, with recursion controlled by a
  209. -- 'RecursionPredicate'. Lazily return a unsorted list of all files
  210. -- matching the given 'FilterPredicate'. Any errors that occur are
  211. -- ignored, with warnings printed to 'stderr'.
  212. findWithInfo
  213. :: Find.RecursionPredicate -- ^ Control recursion into subdirectories.
  214. -> Find.FilterPredicate -- ^ Decide whether a file appears in the result.
  215. -> FilePath -- ^ Directory to start searching.
  216. -> IO [Find.FileInfo] -- ^ Files that matched the 'FilterPredicate'.
  217. findWithInfo recurse filt dir = Find.fold recurse act [] dir
  218. where
  219. -- Add file to list front when it matches the filter
  220. act :: [Find.FileInfo] -> Find.FileInfo -> [Find.FileInfo]
  221. act = flip $ consIf $ Find.evalClause filt
  222. -- | Prepend element if it satisfies the given condition.
  223. consIf :: (a -> Bool) -> a -> [a] -> [a]
  224. consIf p a = if p a then (a :) else id
  225. -- | An Agda file path as test name
  226. asTestName :: FilePath -> FilePath -> String
  227. asTestName testDir path = intercalate "-" parts
  228. where parts = splitDirectories $ dropAgdaExtension $ makeRelative testDir path
  229. doesEnvContain :: String -> IO Bool
  230. doesEnvContain v = isJust <$> getEnvVar v
  231. readTextFile :: FilePath -> IO Text
  232. readTextFile f = decodeUtf8 <$> BS.readFile f
  233. readTextFileMaybe :: FilePath -> IO (Maybe Text)
  234. readTextFileMaybe f = fmap decodeUtf8 <$> readFileMaybe f
  235. writeTextFile :: FilePath -> Text -> IO ()
  236. writeTextFile f = BS.writeFile f . encodeUtf8
  237. -- | Replaces all matches of a regex with the given text.
  238. --
  239. -- (There doesn't seem to be any such function in the regex-tdfa libraries?)
  240. replace :: R.Regex -> Text -> Text -> Text
  241. replace rgx new inp =
  242. foldr repl inp matches
  243. where
  244. -- the matches are in ascending, non-overlapping order. We take advantage
  245. -- of this by replacing the matches in last-to-first order,
  246. -- which means we don't have to worry about changing offsets.
  247. matches = R.matchAll rgx inp
  248. repl :: R.MatchArray -> Text -> Text
  249. repl match t =
  250. T.take off t `T.append` new `T.append` T.drop (off + len) t
  251. where
  252. (off, len) = match ! 0
  253. mkRegex :: Text -> R.Regex
  254. mkRegex r = either (error "Invalid regex") id $
  255. RT.compile R.defaultCompOpt R.defaultExecOpt r
  256. cleanOutput'
  257. :: FilePath -- ^ @pwd@, with slashes rather than backslashes.
  258. -> Text -- ^ Output to sanitize.
  259. -> Text -- ^ Sanitized output.
  260. cleanOutput' pwd t = foldl (\ t' (rgx, n) -> replace rgx n t') t rgxs
  261. where
  262. rgxs = map (first mkRegex)
  263. [ ("[^ (]*test.Fail.", "")
  264. , ("[^ (]*test.Succeed.", "")
  265. , ("[^ (]*test.Common.", "")
  266. , ("[^ (]*test.Bugs.", "")
  267. , ("[^ (]*test.LibSucceed.", "")
  268. , ("\\\\", "/")
  269. -- Andreas, 2021-10-13, issue #5549
  270. -- First, replace backslashes by slashes, then try to match @pwd@,
  271. -- which has already backslashes by slashes replaced.
  272. , (T.pack pwd `T.append` ".test", "..")
  273. , ("\\.hs(:[[:digit:]]+){2}", ".hs:«line»:«col»")
  274. , (T.pack Agda.Version.package, "«Agda-package»")
  275. -- Andreas, 2021-08-26. When run with 'cabal test',
  276. -- Agda.Version.package didn't match, so let's be generous:
  277. -- Andreas, 2021-09-02. The match failure could be triggered
  278. -- when we are running the *installed* version of Agda rather
  279. -- than the *built* one, see .github/workflows/cabal-test.yml.
  280. -- Maybe the match failures will disappear once we drop
  281. -- the workaround for haskell/cabal#7577.
  282. -- Andreas, 2021-08-28. To work around haskell/cabal#7209,
  283. -- "The Grinch stole all the vowels", we also have to
  284. -- recognize Agd (instead of Agda) as package name.
  285. -- See CI run: https://github.com/agda/agda/runs/3449775214?check_suite_focus=true
  286. , ("Agda?-[.0-9]+(-[[:alnum:]]+)?", "«Agda-package»")
  287. , ("[^ (]*lib.prim", "agda-default-include-path")
  288. , ("\xe2\x80\x9b|\xe2\x80\x99|\xe2\x80\x98|`", "'")
  289. ]
  290. cleanOutput :: Text -> IO Text
  291. cleanOutput inp = do
  292. pwd <- getCurrentDirectory
  293. return $ cleanOutput' (map slashify pwd) inp
  294. where
  295. slashify = \case
  296. '\\' -> '/'
  297. c -> c
  298. doesCommandExist :: String -> IO Bool
  299. doesCommandExist cmd = isJust <$> findExecutable cmd
  300. -- | Standard golden value diff that ignores line-breaks and consecutive whitespace.
  301. textDiff :: Text -> Text -> GDiff
  302. textDiff t1 t2 =
  303. if T.words t1 == T.words t2
  304. then
  305. Equal
  306. else
  307. DiffText Nothing t1 t2
  308. -- | Like 'textDiff', but touch given file if golden value does not match.
  309. --
  310. -- (This will take the respective test earlier next time the suite runs.)
  311. textDiffWithTouch :: FilePath -> Text -> Text -> IO GDiff
  312. textDiffWithTouch agdaFile t1 t2
  313. | T.words t1 == T.words t2 = return Equal
  314. | otherwise = do
  315. -- Andreas, 2020-06-09, issue #4736
  316. -- If the output has changed, the test case is "interesting"
  317. -- regardless of whether the golden value is updated or not.
  318. -- Thus, we touch the agdaFile to have it sorted up in the next
  319. -- test run.
  320. -- -- putStrLn $ "TOUCHING " ++ agdaFile
  321. touchFile agdaFile
  322. return $ DiffText Nothing t1 t2
  323. -- | Compare something text-like against the golden file contents.
  324. -- For the conversion of inputs to text you may want to use the Data.Text.Encoding
  325. -- or/and System.Process.Text modules.
  326. --
  327. -- Variant of 'goldenVsAction' that compares golden and actual value
  328. -- word-wise, rather than character-wise, so it is more robust against
  329. -- whitespace differences.
  330. goldenVsAction'
  331. :: TestName -- ^ Test name.
  332. -> FilePath -- ^ Path to the «golden» file (the file that contains correct output).
  333. -> IO a -- ^ Action that returns a text-like value.
  334. -> (a -> T.Text) -- ^ Converts a value to it's textual representation.
  335. -> TestTree -- ^ The test verifies that the returned textual representation
  336. -- is the same as the golden file contents.
  337. goldenVsAction' name ref act toTxt =
  338. goldenTest1
  339. name
  340. (fmap decodeUtf8 <$> readFileMaybe ref)
  341. (toTxt <$> act)
  342. textDiff
  343. ShowText
  344. (BS.writeFile ref . encodeUtf8)