1. Créer un exécutable avec arguments avec Haskell

    2016-08-16
    Source

    La finalité de cet article est de créer, avec Haskell, un exécutable avec des arguments et des options. Nous verrons en outre:

    • comment exécuter une commande système depuis Haskell ;

    • comment rechercher des fichiers dans Haskell avec des jokers (“glob”) ;

    • comment transformer des chemins absolus en chemins relatifs dans Haskell.

    Recherche de fichiers contenant un motif avec grep

    Cette section ne concerne pas Haskell. Nous allons présenter une commande système et plus tard nous implémenterons cette commande dans Haskell.

    Supposons que l’on recherche tous les fichiers de type hs contenant la chaîne de caractères “hello”, à partir du répertoire courant. On peut utiliser la commande

    grep hello *.hs -n -w

    pour chercher uniquement dans le répertoire courant, et la commande

    grep --include=\*.hs -n -w -r -e hello

    pour chercher récursivement à partir dans le répertoire courant, c’est-à-dire dans le répertoire courant, ses sous-répertoires, ses sous-sous répertoires, etc.

    Les options utilisées sont:

    • -n pour afficher les numéros des lignes dans lesquelles apparaissent la chaîne de caractères recherchée ;

    • -w (match whole word) pour rechercher “hello” en tant que mot entier (par exemple hellooo n’est pas pris en compte si on n’utilise pas cette option).

    On peut aussi concaténer les options : grep --include=\*.hs -nwr -e hello.

    Pour les illustrations, nous nous plaçons dans un répertoire avec ce contenu:

    $ tree
    .
    ├── haskell2fa660ff0110.txt
    ├── testfile1.hs
    ├── testfile1.txt
    ├── testfile2.hs
    ├── testfile2.txt
    └── testsubfolder
        ├── testfile01.hs
        ├── testfile01.txt
        ├── testfile02.hs
        ├── testfile02.txt
        └── testsubsubfolder
            ├── testfile001.hs
            ├── testfile001.txt
            ├── testfile002.hs
            └── testfile002.txt
    
    2 directories, 13 files

    Exécuter une commande système dans Haskell

    Dans Haskell, on peut éxecuter la commande précédente ainsi :

    import System.Process
    r <- readCreateProcess (shell "grep --include=\\*.hs -n -w -r -e 'hello'") ""
    putStrLn r
    ## testsubfolder/testsubsubfolder/testfile001.hs:1:let x = "hello"
    ## testsubfolder/testfile01.hs:1:let x = "hello"
    ## testfile1.hs:1:let x = "hello"

    La fonction readProcess permet de passer les options à grep de façon plus commode, dans une liste :

    import System.Process
    r <- readProcess "grep" ["--include", "*.hs", "-n", "-w", "-r", "-e", "hello"] ""
    putStrLn r
    ## testsubfolder/testsubsubfolder/testfile001.hs:1:let x = "hello"
    ## testsubfolder/testfile01.hs:1:let x = "hello"
    ## testfile1.hs:1:let x = "hello"

    Toutefois ces deux fonctions ont un inconvénient. Dans le cas où grep ne trouve aucun fichier correspondant, il retourne un code de sortie d’échec, et ces deux fonctions ne gèrent pas bien cette situation :

    import System.Process
    r <- readProcess "grep" ["--include", "*.hs", "-n", "-w", "-r", "-e", "xxxxx"] ""
    r
    ## *** Exception: readCreateProcess: grep "--include" "*.hs" "-n" "-w" "-r" "-e" "xxxxx" (exit 1): failed

    Il vaut mieux utiliser les fonctions readCreateProcessWithExitCode ou readProcessWithExitCode pour gérer cette situation. Avec ces fonctions on obtient un triplet contenant : le code de sortie, la sortie standard et la sortie d’erreur.

    import System.Process
    (exitcode, stdout, stderr) <- readProcessWithExitCode "grep" ["--include", "*.hs", "-n", "-w", "-r", "-e", "hello"] ""
    (exitcode, stdout, stderr)
    ## (ExitSuccess,"testsubfolder/testsubsubfolder/testfile001.hs:1:let x = \"hello\"\ntestsubfolder/testfile01.hs:1:let x = \"hello\"\ntestfile1.hs:1:let x = \"hello\"\n","")
    import System.Process
    (exitcode, stdout, stderr) <- readProcessWithExitCode "grep" ["--include", "*.hs", "-n", "-w", "-r", "-e", "xxxxx"] ""
    (exitcode, stdout, stderr)
    ## (ExitFailure 1,"","")

    Jokers (glob)

    Quand on exécute la commande

    grep hello *.hs

    l’interpréteur développe *.hs en tous les fichiers hs du répertoire courant. La commande qui est finalement exécutée est

    grep hello testfile1.hs testfile2.hs

    Ce n’est pas grep qui exécute cette tâche, et de ce fait, la commande Haskell

    readProcess "grep" ["hello", "*.hs"] ""

    ne donnera pas le résultat escompté.

    C’est ce qu’on appelle un joker (wildcard), ou “glob” en anglais.

    Les jokers sont aussi utilisés par la commande ls. Rappelons la structure du répertoire courant:

    $ tree
    .
    ├── haskell2fa660ff0110.txt
    ├── testfile1.hs
    ├── testfile1.txt
    ├── testfile2.hs
    ├── testfile2.txt
    └── testsubfolder
        ├── testfile01.hs
        ├── testfile01.txt
        ├── testfile02.hs
        ├── testfile02.txt
        └── testsubsubfolder
            ├── testfile001.hs
            ├── testfile001.txt
            ├── testfile002.hs
            └── testfile002.txt
    
    2 directories, 13 files

    Le joker *.hs correspond à tous les fichiers hs dans le répertoire courant :

    $ ls *.hs
    testfile1.hs
    testfile2.hs

    Le joker */*.hs correspond à tous les fichiers hs dans les sous-répertoires du répertoire courant :

    $ ls */*.hs
    testsubfolder/testfile01.hs
    testsubfolder/testfile02.hs

    Et ainsi de suite :

    $ ls */*/*.hs
    testsubfolder/testsubsubfolder/testfile001.hs
    testsubfolder/testsubsubfolder/testfile002.hs

    On peut ainsi contrôler la profondeur de la recherche quand on exécute grep. Plus tard, quand nous implémenterons grep dans notre exécutable Haskell, nous ajouterons une option pour contrôler la profondeur de la recherche.

    Avant de revenir à Haskell, montrons quelques autres jokers :

    • fichiers hs et txt dans les sous-dossiers :
    $ ls */*.{hs,txt}
    testsubfolder/testfile01.hs
    testsubfolder/testfile01.txt
    testsubfolder/testfile02.hs
    testsubfolder/testfile02.txt
    • fichiers qui ne se terminent pas par t :
    $ ls *.*[!t]
    testfile1.hs
    testfile2.hs
    • en mettant en marche une option au préalable, on peut aussi appliquer la négation à une chaîne de caractères:
    $ shopt -s extglob 
    $ ls *.!(txt)
    testfile1.hs
    testfile2.hs

    Cette option permet d’autres possibilités.

    Revenons à Haskell. Les jokers sont implémentés dans le module System.FilePath.Glob de la librairie glob.

    import System.FilePath.Glob (glob)
    glob "*.hs"
    ## 
    ## <no location info>:
    ##     Ambiguous module name ‘System.FilePath.Glob’:
    ##       it was found in multiple packages:
    ##       Glob-0.7.11@Glob_7Y8nfrHts1W2PKmDbb6BTr filemanip-0.3.6.3@filem_A0TpQXIIDuPEjHKyznzEs3

    La fonction glob retourne des chemins absolus. Si nous les utilisons avec grep, on obtiendra des chemins absolus aussi pour les fichiers trouvés, ce qui encombre la visibilité. On peut les transformer en chemins relatifs à l’aide du module Path de la librairie path :

    import System.FilePath.Glob (glob)
    import System.Directory (getCurrentDirectory)
    import Path (parseAbsFile, parseAbsDir, stripDir, fromRelFile)
    :{
    let absoluteFilePathToRelativeFilePath :: FilePath -> IO( FilePath )
        absoluteFilePathToRelativeFilePath file = 
          do
            currentDir <- getCurrentDirectory
            currentAbsDir <- parseAbsDir currentDir
            absFile <- parseAbsFile file
            relFile <- stripDir currentAbsDir absFile
            return $ fromRelFile relFile
    :}
    absFiles <- glob "*/*.hs"
    mapM absoluteFilePathToRelativeFilePath absFiles
    ## 
    ## <no location info>:
    ##     Ambiguous module name ‘System.FilePath.Glob’:
    ##       it was found in multiple packages:
    ##       Glob-0.7.11@Glob_7Y8nfrHts1W2PKmDbb6BTr filemanip-0.3.6.3@filem_A0TpQXIIDuPEjHKyznzEs3

    Implémentation dans Haskell

    Mettons d’abord ensemble les choses vues précédemment. Nous créons d’abord un module qui transforme des chemins absolus en chemins relatifs au répertoire courant.

    module AbsoluteFilePathToRelativeFilePath where
    
    import Path (parseAbsFile, fromRelFile, parseAbsDir, stripDir)
    import System.Directory (getCurrentDirectory)
    
    absoluteFilePathToRelativeFilePath :: FilePath -> IO( FilePath )
    absoluteFilePathToRelativeFilePath file = 
      do
        currentDir <- getCurrentDirectory
        currentAbsDir <- parseAbsDir currentDir
        absFile <- parseAbsFile file
        relFile <- stripDir currentAbsDir absFile
        return $ fromRelFile relFile

    Nous créons maintenant un module qui exécute la commande grep. Nous mettons un argument depth qui contrôle la profondeur de la recherche. Le type de cet argument est Maybe Int. La recherche récursive sera exécutée si on attribue la valeur Nothing à depth. Le type Maybe Int sera très commode pour la suite, lorsque nous créerons l’exécutable avec des options.

    module GetGrepResults where
    
    import System.Process (readProcessWithExitCode)
    import System.Exit (ExitCode)
    import System.FilePath.Glob (glob)
    import AbsoluteFilePathToRelativeFilePath
    
    runGrep :: String -> String -> Bool -> Maybe Int -> IO(ExitCode, String, String)
    runGrep fileType pattern wholeword depth = 
      do
        let option = if wholeword then "-nw" else "-n"
        case depth of 
           Nothing -> readProcessWithExitCode "grep" ([option] ++ ["--colour=always", "--include", "*." ++ fileType, "-r", "-e", pattern]) ""
           Just n -> do absFiles <- glob $ (foldr (++) "*." (replicate n "*/")) ++ fileType
                        relFiles <- mapM absoluteFilePathToRelativeFilePath absFiles
                        readProcessWithExitCode "grep" ([pattern] ++ relFiles ++ ["--colour=always", option]) ""
           
    getGrepResults :: String -> String -> Bool -> Maybe Int -> IO()
    getGrepResults fileType pattern wholeword depth = 
      do
        (_, stdout, stderr) <- runGrep fileType pattern wholeword depth
        putStrLn "\n--- Results: ---\n"
        case stdout of
          "" -> putStrLn "No result" 
          _ ->  putStrLn stdout

    Création de l’exécutable

    Une librairie excellente et moderne pour créer des exécutables avec arguments et options: [optparse-applicative][http://hackage.haskell.org/package/optparse-applicative].

    On crée d’abord un nouveau type de données Arguments pour les arguments et les options. Puis on modifie notre fonction principale getGrepResults de sorte qu’elle prenne en entrée une variable Arguments.

    Enfin, le code se passe de trop de commentaires tant il est lisible. On trouvera d’autres examples sur la page de la librairie optparse-applicative et ici.

    module Main where
    
    import GetGrepResults
    import Options.Applicative
    import Data.Monoid
    
    data Arguments = Arguments
      { filetype :: String
      , pattern :: String
      , wholeword :: Bool
      , depth :: Maybe Int }
    
    findFiles :: Arguments -> IO()
    findFiles (Arguments filetype pattern w d) = getGrepResults filetype pattern w d  
    
    run :: Parser Arguments
    run = Arguments
         <$> argument str 
              ( metavar "FILETYPE"
             <> help "Type of the files to search in" )
         <*> argument str 
              ( metavar "PATTERN"
             <> help "The pattern to search" )
         <*>  switch
              ( long "wholeword"
             <> short 'w'
             <> help "Match whole word" ) 
         <*> ( optional $ option auto 
              ( metavar "DEPTH"
             <> long "depth"
             <> short 'd'
             <> help "Depth of the search (0: current directory)" ))
    
    main :: IO ()
    main = execParser opts >>= findFiles
      where
        opts = info (helper <*> run)
          ( fullDesc
         <> progDesc "Find files containing a pattern"
         <> header "findpatterninfiles -- based on grep" )

    Ce code étant dans le fichier findpattern.hs, on crée un fichier exécutable findpattern en compilant avec la commande ghc findpattern.hs.

    $ findpattern txt "hello" 
    
    --- Results: ---
    
    testsubfolder/testsubsubfolder/testfile001.txt:1:hello world
    testsubfolder/testfile01.txt:1:hello world
    testfile1.txt:1:hello world
    $ findpattern txt "hello" -d 1
    
    --- Results: ---
    
    testsubfolder/testfile01.txt:1:hello world

    La sortie est en réalité en couleur :

    Note (2016-10-12)

    Plutôt que d’utiliser les jokers, on peut utiliser le module System.FilePath.Find de la librairie filemanip.

    Pour chercher récursivement :

    import System.FilePath.Find
    find always (extension ==? ".hs") "./"
    ## ["./testfile1.hs","./testfile2.hs","./testsubfolder/testfile01.hs","./testsubfolder/testfile02.hs","./testsubfolder/testsubsubfolder/testfile001.hs","./testsubfolder/testsubsubfolder/testfile002.hs"]

    Pour chercher jusqu’à une profondeur donnée :

    import System.FilePath.Find
    find (depth <=? 1) (extension ==? ".hs") "./"
    ## ["./testfile1.hs","./testfile2.hs","./testsubfolder/testfile01.hs","./testsubfolder/testfile02.hs"]