Interface console

Motivation

Si vous avez déjà écrit des programmes qui acceptent des arguments de l’utilisateur en ligne de commande, vous savez que c’est un travail de longue haleine :

  • Il faut gérer mille variantes de la même syntaxe (ex: -xyz vs -x -y -z, --param=valeur vs --param valeur).
  • Dès qu’on essaie d’écrire une commande à la perf ou docker run qui retransmet une partie des arguments un autre programme, les ambiguités de parsing par rapport à nos propres arguments arrivent très vite. Même au sein d’un même programme, elles peuvent être un problème, pensez par exemple à l’ambiguité entre noms de branches et de fichiers dans git.
  • On doit être prêt à ce que l’utilisateur écrive absolument n’importe quoi, y compris de l’Unicode invalide là où un entier est attendu. Sur des petits utilitaires, le code dédié à la gestion de ces erreurs de syntaxe et la production de messages d’erreurs propres pour l’utilisateur peut très facilement prendre plus de place que le reste du programme.
  • Maintenir un texte --help et sa version raccourcie -h en cohérence avec le reste du code au fur et à mesure de son évolution demande une vigilance de tous les instants.
  • Et je ne vous parle pas de l’autocomplétion, qui nécessite l’écriture de code shell spécifique à chaque shell que vos utilisateurs pourraient vouloir utiliser.

En C et en C++, si vous ne tenez pas à la portabilité vers les OS exotiques, le getopt() de POSIX est un outil trop peu exploité qui peut faire une partie du travail pour vous.

Mais même après le parsing de getopt() il en reste encore bien trop à faire pour obtenir une interface en ligne de commande ergonomique et en cohérence avec les conventions des utilitaires Unix usuels, par rapport à l’intérêt que le programmeur moyen a pour cette tâche.

C’est pourquoi l’écosystème Rust a produit des solutions très puissantes à ce problème, qui encapsulent l’essentiel de la complexité dans des bibliothèque générique pouvant être partagée par un grand nombre de programmes. La plus connue d’entre elles est clap.

Utilisation de clap

On commence par ajouter clap en dépendance à son projet avec cargo add, en activant l’excellent support optionnel de derive() :

cargo add clap --features derive

Puis on définit une structure de données décrivant les arguments que nous attendons en entrée, avec des types standard du langages (on peut aussi ajouter le support de ses propres types moyennant un peu plus de travail) :

#![allow(unused)]
fn main() {
struct Arguments {
    speed: f32,
    iterations: u32,
}
}

On documente les différents membres ainsi que la structure. La documentation de la structure servira d’introduction au test --help :

#![allow(unused)]
fn main() {
/// Gray-Scott reaction computation
///
/// This program simulates the Gray-Scott reaction, a sort of Game of Life for
/// chemists. It was developed for the "Gray-Scott Battle" training.
struct Arguments {
    /// Reaction speed
    ///
    /// If you tune this higher, the reaction goes faster.
    speed: f32,

    /// Number of iterations
    ///
    /// The higher you tune this, the longer the reaction will be simulated.
    iterations: u32,
}
}

Et enfin, on demande au compilateur de dériver le trait clap::Parser, qui a comme prérequis le trait Debug, en ajoutant quelques annotations pour clarifier ce qu’on veut que clap génère automatiquement (forme courte des arguments, etc) :

use clap::Parser;

/// Gray-Scott reaction computation
///
/// This program simulates the Gray-Scott reaction, a sort of Game of Life for
/// chemists. It was developed for the "Gray-Scott Battle" training.
#[derive(Debug, Parser)]
#[command(author, version, about, long_about)]
struct Args {
    /// Reaction speed
    ///
    /// If you tune this higher, the reaction goes faster.
    #[arg(short, long, default_value_t = 1.0)]
    speed: f32,

    /// Number of iterations
    ///
    /// The higher you tune this, the longer the reaction will be simulated.
    iterations: u32,
}

Ensuite, plus qu’à déclencher le parsing des arguments dans notre fonction main()

use clap::Parser;

/// Gray-Scott reaction computation
///
/// This program simulates the Gray-Scott reaction, a sort of Game of Life for
/// chemists. It was developed for the "Gray-Scott Battle" training.
#[derive(Debug, Parser)]
#[command(author, version, about, long_about)]
struct Args {
    /// Reaction speed
    ///
    /// If you tune this higher, the reaction goes faster.
    #[arg(short, long, default_value_t = 1.0)]
    speed: f32,

    /// Number of iterations
    ///
    /// The higher you tune this, the longer the reaction will be simulated.
    iterations: u32,
}

fn main() {
    let args = Args::parse();
    println!("{args:?}");
}

…et en ayant fait juste ça, on a une interface en ligne de commande complète avec :

  • Des textes --help et -h automatiquement maintenus en cohérence avec la définition des arguments au niveau de la struct :
     cargo run -- -h
    
    Gray-Scott reaction computation
    
    Usage: grayscott [OPTIONS] <ITERATIONS>
    
    Arguments:
      <ITERATIONS>  Number of iterations
    
    Options:
      -s, --speed <SPEED>  Reaction speed [default: 1]
      -h, --help           Print help (see more with '--help')
      -V, --version        Print version
    
  • Un parsing automatique des arguments vers le type désiré, avec des valeurs par défaut :
    cargo run -- 5
    
    Args { speed: 1.0, iterations: 5 }
    
  • Une vérification des erreurs de saisie de l’utilisateur, avec des messages d’erreurs automatiques quand la saisie est mauvaise, par exemple si l’argument positionnel obligatoire iterations est oublié ici :
    cargo run
    
    error: the following required arguments were not provided:
      <ITERATIONS>
    
    Usage: grayscott <ITERATIONS>
    
    For more information, try '--help'.
    
  • Un accès facile à des fonctionnalités plus avancées telles que…
    • La possibilité de passer aussi les arguments par variable d’environnement.
    • Le chargement automatique des valeurs d’arguments depuis un fichier.
    • La génération de pages de man.
    • La génération de scripts shells d’autocomplétion.

Conclusion

On comprend pourquoi autant d’utilitaires en ligne de commande sont développés en Rust aujourd’hui. Le niveau de maturité du langage pour ce type d’utilisation est excellent, grâce au typage explicite des arguments il parvient même à dépasser le niveau de confort fourni par Python qui a longtemps été la référence du domaine.

Si d’aventure vous voulez écrire des interfaces textuelles plus sophistiquées qui reproduisent le comportement des GUIs, à la manière de perf report et zenith, je vous encourage à étudier aussi les bibliothèques ratatui et cursive qui sont conçues pour ce type d’utilisation. La première est plus spécialisée dans la visualisation temps réel (pensez “moniteur système”), la seconde dans les interfaces hautement interactives (pensez “perf report”).