Props.hs
1 {-# LANGUAGE OverloadedStrings #-} 2 {-# LANGUAGE RecordWildCards #-} 3 4 {- | 5 Module : Props 6 Description : Property tests for CLI wrapper generators 7 8 Run: 9 nix-shell -p "haskellPackages.ghcWithPackages (p: [p.megaparsec p.text p.QuickCheck p.hedgehog])" \ 10 --run "runghc -i. Props.hs" 11 12 Properties tested: 13 1. Parser totality: no crashes on arbitrary input 14 2. Preservation: parsed options appear in generated code 15 3. Idempotence: parse(x) == parse(x) 16 4. Compilation: generated code is valid Haskell (via GHC) 17 -} 18 module Main where 19 20 import Control.DeepSeq (NFData (..), deepseq) 21 import Data.Char (isAlphaNum, isLower, isUpper) 22 import Data.Text (Text) 23 import qualified Data.Text as T 24 import System.Directory (getTemporaryDirectory, removeFile) 25 import System.Exit (ExitCode (..)) 26 import System.IO (hClose, hPutStr, openTempFile) 27 import System.Process (readProcessWithExitCode) 28 import Test.QuickCheck 29 30 import qualified Aleph.Script.Clap as Clap 31 import qualified Aleph.Script.Getopt as Getopt 32 33 -- ============================================================================ 34 -- Generators 35 -- ============================================================================ 36 37 -- | Generate realistic-ish help text lines 38 genHelpLine :: Gen Text 39 genHelpLine = 40 frequency 41 [ (3, genOptionLine) 42 , (1, genSectionHeader) 43 , (2, genDescriptionLine) 44 , (1, pure "") 45 ] 46 47 -- | Generate a clap-style option line 48 genOptionLine :: Gen Text 49 genOptionLine = do 50 indent <- elements [" ", " ", " "] 51 short <- frequency [(3, genShortOpt), (1, pure "")] 52 long <- frequency [(4, genLongOpt), (1, pure "")] 53 sep <- 54 if (not (T.null short) && not (T.null long)) 55 then elements [", ", " "] 56 else pure "" 57 arg <- frequency [(2, genArgPlaceholder), (1, pure "")] 58 desc <- genDescription 59 pure $ indent <> short <> sep <> long <> arg <> " " <> desc 60 61 genShortOpt :: Gen Text 62 genShortOpt = do 63 c <- elements $ ['a' .. 'z'] ++ ['A' .. 'Z'] ++ ['0' .. '9'] 64 pure $ "-" <> T.singleton c 65 66 genLongOpt :: Gen Text 67 genLongOpt = do 68 parts <- listOf1 $ elements ["foo", "bar", "ignore", "case", "file", "path", "no", "with"] 69 pure $ "--" <> T.intercalate "-" (take 3 parts) 70 71 genArgPlaceholder :: Gen Text 72 genArgPlaceholder = 73 frequency 74 [ 75 ( 2 76 , do 77 name <- elements ["FILE", "PATH", "NUM", "PATTERN", "DIR", "COUNT"] 78 pure $ "=" <> name 79 ) 80 , 81 ( 1 82 , do 83 name <- elements ["FILE", "PATH", "NUM"] 84 pure $ " <" <> name <> ">" 85 ) 86 ] 87 88 genSectionHeader :: Gen Text 89 genSectionHeader = do 90 name <- elements ["Options", "Input", "Output", "Search", "Filter", "POSITIONAL ARGUMENTS"] 91 pure $ name <> ":" 92 93 genDescriptionLine :: Gen Text 94 genDescriptionLine = do 95 indent <- elements [" ", " ", " "] 96 desc <- genDescription 97 pure $ indent <> desc 98 99 genDescription :: Gen Text 100 genDescription = do 101 words <- listOf $ elements ["the", "a", "file", "to", "use", "for", "output", "input", "match"] 102 pure $ T.unwords (take 10 words) 103 104 -- | Generate a full help text (clap-style) 105 genHelpText :: Gen Text 106 genHelpText = do 107 header <- elements ["mytool 1.0.0", "Usage: mytool [OPTIONS]", ""] 108 sections <- listOf1 $ do 109 hdr <- genSectionHeader 110 opts <- listOf1 genOptionLine 111 pure $ T.unlines (hdr : opts) 112 pure $ T.unlines (header : sections) 113 114 -- | Generate GNU-style help text 115 genGnuHelpText :: Gen Text 116 genGnuHelpText = do 117 header <- elements ["Usage: mytool [OPTION]... [FILE]...", "mytool - do something"] 118 opts <- listOf1 genGnuOptionLine 119 pure $ T.unlines (header : opts) 120 121 -- | Generate a GNU getopt-style option line 122 genGnuOptionLine :: Gen Text 123 genGnuOptionLine = do 124 short <- frequency [(3, Just <$> elements ['a' .. 'z']), (1, pure Nothing)] 125 long <- frequency [(4, Just <$> genLongName), (1, pure Nothing)] 126 arg <- frequency [(2, Just <$> elements ["FILE", "NUM", "DIR"]), (1, pure Nothing)] 127 desc <- genDescription 128 let shortPart = maybe "" (\c -> "-" <> T.singleton c <> ", ") short 129 longPart = maybe "" (\l -> "--" <> l) long 130 argPart = maybe "" ("=" <>) arg 131 case (short, long) of 132 (Nothing, Nothing) -> genGnuOptionLine -- retry, need at least one 133 _ -> pure $ " " <> shortPart <> longPart <> argPart <> " " <> desc 134 where 135 genLongName = do 136 parts <- listOf1 $ elements ["foo", "bar", "ignore", "case", "file"] 137 pure $ T.intercalate "-" (take 2 parts) 138 139 -- | Generate completely random text (for crash testing) 140 genArbitraryText :: Gen Text 141 genArbitraryText = do 142 len <- choose (0, 1000) 143 chars <- vectorOf len arbitrary 144 pure $ T.pack chars 145 146 -- ============================================================================ 147 -- Properties 148 -- ============================================================================ 149 150 -- | Parser never crashes, even on garbage input 151 prop_clap_parser_total :: Property 152 prop_clap_parser_total = forAll genArbitraryText $ \input -> 153 let result = Clap.parseHelp input 154 in result `deepseq` True -- Force evaluation, just check it doesn't crash 155 156 prop_getopt_parser_total :: Property 157 prop_getopt_parser_total = forAll genArbitraryText $ \input -> 158 let result = Getopt.parseHelp input 159 in result `deepseq` True 160 161 -- | Parsing is idempotent (parse twice = same result) 162 prop_clap_idempotent :: Property 163 prop_clap_idempotent = forAll genHelpText $ \input -> 164 let p1 = Clap.parseHelp input 165 p2 = Clap.parseHelp input 166 in p1 == p2 167 168 prop_getopt_idempotent :: Property 169 prop_getopt_idempotent = forAll genHelpText $ \input -> 170 let p1 = Getopt.parseHelp input 171 p2 = Getopt.parseHelp input 172 in p1 == p2 173 174 -- | Every long option in parsed result appears in generated code 175 prop_clap_options_preserved :: Property 176 prop_clap_options_preserved = forAll genHelpText $ \input -> 177 let parsed = Clap.parseHelp input 178 generated = Clap.generateModule "Test" "test" parsed 179 allOpts = concatMap Clap.secOptions (Clap.helpSections parsed) 180 longNames = 181 [ n 182 | Clap.ClapOption{optLong = Just n} <- allOpts 183 , n /= "help" 184 , n /= "version" 185 ] 186 in all (\n -> n `T.isInfixOf` generated) longNames 187 188 prop_getopt_options_preserved :: Property 189 prop_getopt_options_preserved = forAll genGnuHelpText $ \input -> 190 let parsed = Getopt.parseHelp input 191 generated = Getopt.generateModule "Test" "test" parsed 192 opts = Getopt.helpOptions parsed 193 longNames = 194 [ n 195 | Getopt.GetoptOption{Getopt.optLong = Just n} <- opts 196 , n /= "help" 197 , n /= "version" 198 ] 199 in all (\n -> n `T.isInfixOf` generated) longNames 200 201 -- | Generated code contains required structure 202 prop_clap_generated_structure :: Property 203 prop_clap_generated_structure = forAll genHelpText $ \input -> 204 let generated = Clap.generateModule "Test" "test" (Clap.parseHelp input) 205 in and 206 [ "module Aleph.Script.Tools.Test" `T.isInfixOf` generated 207 , "data Options = Options" `T.isInfixOf` generated 208 , "defaults :: Options" `T.isInfixOf` generated 209 , "buildArgs :: Options -> [Text]" `T.isInfixOf` generated 210 , "test :: Options -> [Text] -> Sh Text" `T.isInfixOf` generated 211 ] 212 213 prop_getopt_generated_structure :: Property 214 prop_getopt_generated_structure = forAll genGnuHelpText $ \input -> 215 let generated = Getopt.generateModule "Test" "test" (Getopt.parseHelp input) 216 in and 217 [ "module Aleph.Script.Tools.Test" `T.isInfixOf` generated 218 , "data Options = Options" `T.isInfixOf` generated 219 , "defaults :: Options" `T.isInfixOf` generated 220 , "buildArgs :: Options -> [Text]" `T.isInfixOf` generated 221 ] 222 223 -- | Field names are valid Haskell identifiers 224 prop_clap_valid_field_names :: Property 225 prop_clap_valid_field_names = forAll genLongOpt $ \opt -> 226 let name = Clap.optionToHaskellName (T.drop 2 opt) -- drop "--" 227 in not (T.null name) && isValidHaskellIdent name 228 229 prop_getopt_valid_field_names :: Property 230 prop_getopt_valid_field_names = forAll genLongOpt $ \opt -> 231 let name = Getopt.optionToHaskellName (T.drop 2 opt) 232 in not (T.null name) && isValidHaskellIdent name 233 234 isValidHaskellIdent :: Text -> Bool 235 isValidHaskellIdent t = case T.uncons t of 236 Nothing -> False 237 Just (c, rest) -> 238 (isLower c || c == '_') 239 && T.all (\x -> isAlphaNum x || x == '_' || x == '\'') rest 240 241 -- ============================================================================ 242 -- Compilation Properties (IO) 243 -- ============================================================================ 244 245 -- | Generated clap code compiles with GHC 246 prop_clap_compiles :: Property 247 prop_clap_compiles = once $ ioProperty $ do 248 let input = 249 T.unlines 250 [ "Options:" 251 , " -v, --verbose Be verbose" 252 , " -f, --file=FILE Input file" 253 , " -n, --count=NUM Number of items" 254 , " --ignore-case Ignore case" 255 ] 256 generated = Clap.generateModule "TestClap" "test-clap" (Clap.parseHelp input) 257 compiles generated 258 259 -- | Generated getopt code compiles with GHC 260 prop_getopt_compiles :: Property 261 prop_getopt_compiles = once $ ioProperty $ do 262 let input = 263 T.unlines 264 [ "Usage: test [OPTION]..." 265 , " -v, --verbose be verbose" 266 , " -f, --file=FILE input file" 267 , " -n, --count=NUM number of items" 268 , " --ignore-case ignore case" 269 ] 270 generated = Getopt.generateModule "TestGetopt" "test-getopt" (Getopt.parseHelp input) 271 compiles generated 272 273 -- | Generated code from random input compiles 274 prop_clap_random_compiles :: Property 275 prop_clap_random_compiles = withMaxSuccess 5 $ forAll genHelpText $ \input -> 276 ioProperty $ do 277 let generated = Clap.generateModule "TestRandom" "test-random" (Clap.parseHelp input) 278 compiles generated 279 280 prop_getopt_random_compiles :: Property 281 prop_getopt_random_compiles = withMaxSuccess 5 $ forAll genGnuHelpText $ \input -> 282 ioProperty $ do 283 let generated = Getopt.generateModule "TestRandom" "test-random" (Getopt.parseHelp input) 284 compiles generated 285 286 -- | Check if Haskell code compiles using GHC 287 compiles :: Text -> IO Bool 288 compiles code = do 289 tmpDir <- getTemporaryDirectory 290 (tmpFile, h) <- openTempFile tmpDir "PropTest.hs" 291 hPutStr h (T.unpack code) 292 hClose h 293 -- Use -fno-code to just type-check without generating output 294 -- -i. to find Aleph.Script module 295 (exitCode, _stdout, stderr) <- 296 readProcessWithExitCode 297 "ghc" 298 ["-fno-code", "-i.", tmpFile] 299 "" 300 removeFile tmpFile 301 case exitCode of 302 ExitSuccess -> return True 303 ExitFailure _ -> do 304 putStrLn $ "\n=== COMPILATION FAILED ===" 305 putStrLn $ "Generated code:\n" ++ T.unpack code 306 putStrLn $ "GHC error:\n" ++ stderr 307 putStrLn "===========================" 308 return False 309 310 -- ============================================================================ 311 -- NFData instances for deepseq 312 -- ============================================================================ 313 314 instance NFData Clap.ClapOption where 315 rnf Clap.ClapOption{..} = rnf optShort `seq` rnf optLong `seq` rnf optArg `seq` rnf optDesc 316 317 instance NFData Clap.ClapPositional where 318 rnf Clap.ClapPositional{..} = rnf posName `seq` rnf posRequired 319 320 instance NFData Clap.ClapSection where 321 rnf Clap.ClapSection{..} = rnf secName `seq` rnf secOptions 322 323 instance NFData Clap.ClapHelp where 324 rnf Clap.ClapHelp{..} = rnf helpSections `seq` rnf helpPositionals 325 326 instance NFData Getopt.GetoptOption where 327 rnf Getopt.GetoptOption{..} = 328 rnf optShort `seq` 329 rnf optLong `seq` 330 rnf optArg `seq` 331 rnf optArgOptional `seq` 332 rnf optDesc 333 334 instance NFData Getopt.GetoptHelp where 335 rnf Getopt.GetoptHelp{..} = rnf helpOptions `seq` rnf helpUsage 336 337 -- ============================================================================ 338 -- Main 339 -- ============================================================================ 340 341 main :: IO () 342 main = do 343 putStrLn "=== Property Tests for CLI Wrapper Generators ===" 344 putStrLn "" 345 346 let args = stdArgs{maxSuccess = 50} -- 50 is fast enough for quick checks 347 putStrLn "-- Clap Parser --" 348 putStr " parser_total: " 349 quickCheckWith args prop_clap_parser_total 350 putStr " idempotent: " 351 quickCheckWith args prop_clap_idempotent 352 putStr " options_preserved: " 353 quickCheckWith args prop_clap_options_preserved 354 putStr " generated_structure: " 355 quickCheckWith args prop_clap_generated_structure 356 putStr " valid_field_names: " 357 quickCheckWith args prop_clap_valid_field_names 358 359 putStrLn "" 360 putStrLn "-- GNU Getopt Parser --" 361 putStr " parser_total: " 362 quickCheckWith args prop_getopt_parser_total 363 putStr " idempotent: " 364 quickCheckWith args prop_getopt_idempotent 365 putStr " options_preserved: " 366 quickCheckWith args prop_getopt_options_preserved 367 putStr " generated_structure: " 368 quickCheckWith args prop_getopt_generated_structure 369 putStr " valid_field_names: " 370 quickCheckWith args prop_getopt_valid_field_names 371 372 -- Compilation tests are slow (spawn GHC subprocess), skip by default 373 -- Uncomment to run: 374 -- putStrLn "" 375 -- putStrLn "-- Compilation (requires GHC) --" 376 -- putStr " clap_compiles: " 377 -- quickCheckWith args prop_clap_compiles 378 -- putStr " getopt_compiles: " 379 -- quickCheckWith args prop_getopt_compiles 380 381 putStrLn "" 382 putStrLn "=== All properties passed ==="