Le code source de cet exemple peut être téléchargé ici.
Nous avons réalisé dans le tutoriel "Tutoriel REPL" une boucle REPL très basique. L'interface ne contient pas les fonctionnalités qui permettent de la rendre pratique à utiliser telles que :
L'historique des commandes.
La complétion automatique.
Pour palier à cela, nous allons voir comment utiliser la bibliothèque Haskeline
qui apporte les fonctionnalités qui nous manque.
Haskeline est une bibliothèque Haskell qui apporte les mêmes fonctionnalités que readline (écrite en C). Elle est fiable et facile à utiliser.
On peut créer et utiliser une ligne de commande avec Haskeline de 2 manières:
La première, en lançant une monade propre à cette bibliothèque InputT m a
qui gère la ligne de commande et sur laquelle on va lancer les différentes fonctions que l'on souhaite lancer.
La seconde, en appelant la ligne de commande à l'intérieur d'une monade IO
aux moments ou on le souhaite.
Cette seconde solution présente l'avantage de pouvoir appeler des fonctions de type IO a
sans avoir à passer par la fonction liftIO
ce qui simplifie le développement de programme avec beaucoup d'accès au système.
Dans le tutoriel qui va suivre, c'est cette seconde solution que je vais présenter.
La boucle REPL reprend globalement la même structure que précédemment avec les mêmes fonctionnalités.
Pour commencer, il faut initialiser la ligne de commande avec initializeInput
avec les préférences utilisateurs définis dans mySettings
.
Permet de définir une fonction pour la complétion automatique. Pour l'instant, la complétion est désactivée avec noCompletion
nous verrons plus loin comment l'utiliser.
Un possible fichier d'historique ou les commandes seront enregistrées et récupérées lors d'une autre session.
Ajoute automatiquement les commandes tappées à l'historique.
La ligne de commande initialisée avec initializeInput
doit être passée comme argument dans la boucle REPL afin de pouvoir être utilisée pendant la boucle.
main = do
inp <- initializeInput mySettings
putStrLn $ unlines help
replLoop inp 1
mySettings = Settings {
complete = noCompletion
, historyFile = Just "history.hist"
, autoAddHistory = True
}
C'est lors de la phase de lecture que l'on va prendre et utiliser l'entrée haskeline. On la lance avec queryInput
en donnant comme argument la ligne créée et les commandes à lancer.
Ici, on utilise getInputLine
en donnant la chaîne de caractère à utiliser comme invite. La fonction lance une ligne de saisie vide ou l'utilisateur tape sa commande qui apparait en clair à l'écran.
Il est à noter que Haskeline apporte d'autres fonctions pour la ligne de saisie comme:
getPassword
:
qui lance une saisie en masquant ce que tape l'utilisateur. Idéal pour un mot de passe comme son nom l'indique.
getInputLineWithInitial
:
qui lance une saisie comme getInputLine
mais avec une ligne pré-rempli.
replRead inp i= do
com <- queryInput inp (getInputLine ("ma commande "++show i++" >"))
return com
L'évaluation se fait en analysant la chaîne de caractère de la commande et en exécutant les actions correspondantes comme déjà expliqué dans le tutoriel précédent.
La seule différence se situe au niveau de la sortie du programme. En effet, il est impératif de clôturer correctement la ligne de commande avec la fonction closeInput
sans quoi, l'historique ne sera pas sauvegardé dans le fichier.
replEval inp com@(':' : 'q' : 'u' : 'i' : 't' : 't' : 'e' : 'r' : _ ) = do
putStrLn "Au revoir !"
closeInput inp
exitSuccess
return []
L'affichage se fait de la même manière que dans le tutoriel précédent
La boucle se lance via une fonction qui lance les différentes étapes de la boucle avant de s'appeler récursivement elle-même.
La petite différence vient du fait que la ligne de commande Haskeline retourne le type Maybe String
que l'on analyse avec un case
.
replLoop inp i = do
mbcom <- replRead inp i
case mbcom of
Nothing -> return ()
Just com -> do
res <- replEval inp com
replPrint res
replLoop inp (i + 1)
Nous avons maintenant une ligne de commande fonctionnelle, mais il manque encore la complétion automatique. Pour la faire fonctionner, il faut créer une fonction spécifique à passer dans le type Setting
lors du lancement de la ligne de commande.
Cette fonction (qui fonctionne au sein d'une monade) a comme signature CompletionFunc m = (String, String) -> m (String, [Completion])
et prends comme argument un couple de chaîne de caractères provenant de la ligne de commande Haskeline et contenant la portion avant le curseur et la portion située après le curseur.
Des fonctions permettant de générer des complétions personnalisées sont disponibles et facilite le travail de développement.
Par exemple completeFilename
permet de lister les fichiers du répertoire courant et d'avoir accès à leurs noms en complétion.
On trouve également completeWord
qui permet de compléter la commande en cours avec comme arguments:
Un possible caractère d'échappement
Une liste de caractères considérés comme des espaces
Une fonction qui retourne une liste de Completion
en fonction du début de la ligne de commande.
C'est cette fonction que nous allons utiliser dans ce tutoriel pour faire ce que l'on veut. D'ailleurs que veut-on ?
Dans un premier temps, on veut que les commandes disponibles puissent être remplies automatiquement
Dans un deuxième temps, que lorsque la commande :info est complètement tapée, la complétion propose la liste des fichiers disponibles dans le répertoire courant.
Pour compléter les commandes, nous allons simplement créer une liste de complétions en fonction de la commande qui a commencé à être tapée.
Pour cela, on crée une liste des commandes disponibles associées à une petite explication sur ce que fait la fonction.
Cette liste est filtrée avec la fonction isPrefixOf
pour tester si les commandes de la liste commencent par la chaine de la ligne commande. Le résultat filtré est "mappé" pour créer une liste de complétion contenant la commande complète et l'explication sur cette commande qui sera affichée dans les suggestions.
searchFunc str = return $ map (\(a, b) -> Completion a (a ++ " *" ++ b) False) lstcomp
where
lstcomp = filter
(\(a, b) -> str `isPrefixOf` a)
[ (":ls" , "Liste les fichiers du répertoire courant")
, (":info" , "Donne des informations sur un fichier")
, (":heure" , "Donne l'heure du système")
, (":date" , "Donne la date du système")
, (":quitter", "Quitte le programme")
]
Pour compléter la commande :info avec un nom de fichiers valide, on commence par reconnaitre la commande avec une empreinte et on lance alors les fonctions nécessaires au listing des noms de fichiers.
On récupère le répertoire courant avec getCurrentDirectory
et le contenu de ce répertoire avec getDirectoryContents
. On filtre ensuite les résultats afin de supprimer les entrées qui ne commence pas par le nom de fichier déjà renseigné par l'utilisateur et les entrées spéciales . et ...
Le résultat final est "mappé" pour créer une liste de complétion contenant la commande :info suivi par un espace et le nom de fichier possible Les suggestions contiennent uniquement les noms des fichiers.
searchFunc inp@(':' : 'i' : 'n' : 'f' : 'o' : _) = do
dir <- getCurrentDirectory
content <- getDirectoryContents dir
let filcomp = dropWhile (== ' ') $ dropWhile (/= ' ') inp
filtered = filter (\f -> notElem f [".", ".."] && (filcomp `isPrefixOf` f)) content
return $ map (\a -> Completion (":info " ++ a) a False) filtered
Nous avons maintenant une ligne de commande complète, facilement utilisable et contenant tout ce qu'il faut pour faire de beaux programmes interactifs en ligne de commande.
Évidemment, des modifications seraient à apporter pour permettre au programme de gérer des lignes de commandes plus complexes.
La première modification à apporter serait d'utiliser un type dédié pour décrire l'ensemble des commandes utilisables:
data Commandes =
| ComInfo
| ComLs
| ComHeure
| ComDate
Une autre modification seraient d'utiliser les bibliothèques parsec
ou megaparsec
afin d'analyser des lignes de commandes plus complexes