Close search
Hoa

Hack book de Hoa\Console

Le terminal est une interface très puissante qui repose sur de multiples concepts. Hoa\Console permet d'écrire des outils adaptés à ce type d'environnement.

Table des matières

  1. Introduction
  2. Fenêtre
    1. Taille et position
    2. Titre et label
    3. Interagir avec le contenu
  3. Curseur
    1. Déplacement
    2. Affichage
    3. Style
    4. Son
  4. Lecture en ligne
    1. Usage basique
    2. Raccourcis
    3. Auto-complétion
  5. Lecture d'options
    1. Analyser les options
    2. Lire les options et les entrées
    3. Options spéciales ou ambiguës
    4. Intégrer un routeur et un dispatcheur
  6. Processus
    1. Exécution très basique
    2. Lecture et écriture
    3. Détecter le type des pipes
    4. Condition d'exécution
    5. Miscellaneous
    6. Processus interactifs et pseudo-terminaux
  7. Conclusion

Introduction

De nos jours, nous comptons deux types d'interfaces : textuelle et graphique. L'interface textuelle existe depuis l'origine des ordinateurs, alors appelés terminaux. Cette interface, malgré son aspect « brut », est fonctionnellement très puissante grâce à plusieurs concepts comme par exemple la ligne de commande ou les pipes. Aujourd'hui, elle est encore très utilisée car elle est souvent plus rapide pour exécuter des tâches complexes qu'une interface graphique. Elle peut être aussi très facilement utilisée à travers des réseaux ou sur des machines à faibles ressources. Bref, cette interface est toujours incontournable.

Du point de vue de l'utilisateur, il y a trois niveaux à considérer :

La bibliothèque Hoa\Console propose des outils pour répondre à ces trois niveaux de problématique. Pour cela, elle se base sur des standards, comme l'ECMA-48 qui spécifie la communication avec le système à travers des suites de caractères ASCII et des codes de contrôle (aussi appelés séquences d'échappement), ce afin de manipuler la fenêtre, le curseur ou des périphériques de la machine. D'autres fonctionnalités sont aussi standards comme la manière de lire des options depuis un programme, très inspirée de systèmes comme Linux, FreeBSD ou encore System V. D'ailleurs, si vous êtes familier avec plusieurs bibliothèques C, vous ne serez pas déroutés. Et a contrario, si vous apprenez à utiliser Hoa\Console, vous ne serez pas perdus en retournant sur des langages de plus bas niveaux comme le C.

Avant de commmencer, nous aimerions ajouter une petite note uniquement à propos de la gestion de la fenêtre et du curseur. Aujourd'hui, nous avons le choix entre plusieurs terminaux par système et certains sont plus complets que d'autres. Par exemple, Windows et son terminal par défaut, le MS-DOS, ne respecte aucun standard. Dans ce cas, oubliez le standard ECMA-48 et tournez-vous vers la bibliothèque Wincon. Il est souvent recommandé d'utiliser une machine Unix virtuelle ou un émulateur de terminal, comme TeraTerm, très complet. Même sur des systèmes proches de la famille BSD, les terminaux distribués par défaut ne supportent pas tous les standards. C'est le cas de Mac OS X, où nous vous conseillons d'utiliser iTerm2 au lieu de Terminal. Enfin, sur d'autres systèmes de la famille Linux ou BSD, nous conseillons urxvt. Pour les autres fonctionnalités, comme la lecture en ligne, la lecture d'options, les processus etc., Hoa\Console est parfaitement compatible et fonctionnel.

Fenêtre

La fenêtre d'un terminal doit être vue comme un canevas de colonnes et de lignes. La classe Hoa\Console\Window permet de manipuler la fenêtre du terminal et son contenu à travers des méthodes statiques.

Taille et position

Les premières opérations élémentaires concernent la taille et la position de la fenêtre, grâce aux méthode setSize, getSize, moveTo et getPosition. La taille se définie avec les unités colonne × ligne et la position se définie en pixels. Ainsi :

Hoa\Console\Window::setSize(80, 50);
print_r(Hoa\Console\Window::getSize());
print_r(Hoa\Console\Window::getPosition());

/**
 * Will output:
 *     Array
 *     (
 *         [x] => 80
 *         [y] => 50
 *     )
 *     Array
 *     (
 *         [x] => 104
 *         [y] => 175
 *     )
 */

Nous remarquerons que la fenêtre se redimensionne toute seule. Ni la taille ni la position de la fenêtre ne sont stockées en mémoire, elles sont calculées à chaque appel de la méthode getSize et getPosition. Attention, l'axe y de la position de la fenêtre se calcule depuis le bas de l'écran et non pas depuis le haut de l'écran comme nous pourrions nous y attendre !

Il est aussi possible d'écouter l'événement hoa://Event/Console/Window:resize qui est lancé à chaque fois que la fenêtre est redimensionnée : soit manuellement, soit avec la méthode setSize. Nous avons besoin de deux choses pour que cet événement fonctionne :

  1. l'extension pcntl doit être activée ;
  2. nous devons utiliser la structure declare pour que la fonction pcntl_signal fonctionne correctement.

Pour mettre le programme en attente passive, nous allons utiliser la fonction stream_select, c'est un détail présent uniquement pour tester notre code, sinon le programme se terminerait tout de suite. Ainsi :

Consistency\Autoloader::load('Hoa\Console\Window'); // make sure it is loaded.

declare(ticks = 1);

Hoa\Event\Event::getEvent('hoa://Event/Console/Window:resize')
    ->attach(function (Hoa\Event\Bucket $bucket) {
        $data = $bucket->getData();
        $size = $data['size'];

        echo 'New size (', $size['x'], ', ', $size['y'], ')', "\n";
    });

// Passive loop.
while (true) {
    $r = [STDIN];
    @stream_select($r, $w, $e, 3600);
}

Lorsque nous modifions la taille de la fenêtre, nous verrons s'afficher par exemple : New size (45, 67), et ce pour chaque redimensionnement. Cet événement est intéressant si nous voulons ré-adapter notre présentation.

Enfin, nous pouvons minimiser ou restaurer la fenêtre grâce aux méthodes statiques Hoa\Console\Window::minimize et Hoa\Console\Window::restore. Par ailleurs, nous pouvons placer la fenêtre en arrière-plan (derrière toutes les autres fenêtres) grâce à la méthode statique Hoa\Console\Window::lower, tout comme nous pouvons la placer en avant-plan avec Hoa\Console\Window::raise. Par exemple :

Hoa\Console\Window::minimize();
sleep(2);
Hoa\Console\Window::restore();
sleep(2);
Hoa\Console\Window::lower();
sleep(2);
Hoa\Console\Window::raise();

echo 'Back!', "\n";

Titre et label

Le titre d'une fenêtre correspond au texte affiché dans sa barre supérieure, dans laquelle sont souvent placés les contrôles de la fenêtre comme la maximisation, la minimisation etc. Le label correspond au nom associé au processus actuel. Nous trouvons les méthodes setTitle, getTitle et getLabel, il n'est pas prévu de modifier le label. Pour définir le titre du processus (ce que nous voyons avec la commande top ou ps par exemple), il faudra se référer à Hoa\Console\Processus::setTitle et à Hoa\Console\Processus::getTitle pour l'obtenir. Ainsi :

Hoa\Console\Window::setTitle('Foobar');
var_dump(Hoa\Console\Window::getTitle());
var_dump(Hoa\Console\Window::getLabel());

/**
 * Will output:
 *     string(6) "Foobar"
 *     string(3) "php"
 */

Encore une fois, le titre et le label ne sont pas stockés en mémoire, ils sont calculés à chaque appel de méthode.

Interagir avec le contenu

Hoa\Console\Window permet aussi de contrôler le contenu de la fenêtre, ou du moins le viewport, c'est à dire le contenu visible de la fenêtre. Une seule méthode est actuellement disponible : scroll, qui permet de déplacer le contenu vers le haut ou vers le bas. Les arguments de cette méthode sont très simples : up ou pour monter d'une ligne, et down ou pour descendre d'une ligne. Nous pouvons concaténer ces directions par un espace ou alors préciser le nombre de fois où une direction sera répétée :

Hoa\Console\Window::scroll('↑', 10);

En réalité, cette méthode va déplacer le contenu pour qu'il y ait x lignes respectivement en-dessous ou au-dessus du curseur. Attention, le curseur ne change pas de position !

Même si c'est très souvent inutile, il est possible de rafraîchir la fenêtre, c'est à dire de refaire un rendu complet. Nous pouvons nous aider de la méthode refresh toujour sur Hoa\Console\Window.

Enfin, il est possible de placer un texte dans le presse-papier de l'utilisateur à l'aide de la méthode copy :

Hoa\Console\Window::copy('Foobar');

Puis si l'utilisateur colle ce qui est dans son presse-papier, il verra Foobar s'afficher.

Curseur

À l'intérieur d'une fenêtre, nous avons un curseur qui peut être vu comme la pointe d'un stylo. La classe Hoa\Console\Cursor permet de manipuler le curseur du terminal à travers des méthodes statiques.

Déplacement

Nous allons commencer par déplacer le curseur. Il se déplace partout dans le viewport, c'est à dire le contenu visible de la fenêtre du terminal, mais nous allons écrire un peu de texte et nous déplacer dedans dans un premier temps. La méthode move sur Hoa\Console\Cursor permet de déplacer le curseur dans plusieurs directions. Tout d'abord de manière relative :

Nous trouvons aussi des déplacements semi-absolus :

Ces directions peuvent être concaténées par des espaces, ou alors nous pouvons préciser le nombre de fois où une direction sera répétée.

echo
    'abcdef', "\n",
    'ghijkl', "\n",
    'mnopqr', "\n",
    'stuvwx';

sleep(1);
Hoa\Console\Cursor::move('↑');
sleep(1);
Hoa\Console\Cursor::move('↑ ←');
sleep(1);
Hoa\Console\Cursor::move('←', 3);
sleep(1);
Hoa\Console\Cursor::move('DOWN');
sleep(1);
Hoa\Console\Cursor::move('→', 4);

Lors de l'exécution, nous verrons le curseur se déplacer tout seul de « lettre en lettre » toutes les secondes.

Pour réellement déplacer le curseur de manière absolue, nous utiliserons la méthode moveTo qui prend en argument des coordonnées en colonne × ligne (la numérotation commence à 1 et non pas à 0). Nous en profitons pour parler de la méthode getPosition qui permet de connaître la position du curseur. Ainsi, si nous voulons déplacer le curseur à la colonne 12 et à la ligne 7, puis afficher ces coordonnées, nous écrirons :

Hoa\Console\Cursor::moveTo(12, 7);
print_r(Hoa\Console\Cursor::getPosition());

/**
 * Will output:
 *     Array(
 *         [x] => 12
 *         [y] => 7
 *     )
 */

Enfin, il arrive très régulièrement que nous voulions déplacer le curseur temporairement pour quelques opérations. Dans ce cas, il est inutile de récupérer la position actuelle, le déplacer, puis le repositionner ; nous pouvons profiter des méthodes save et restore. Comme leur nom l'indique, ces méthodes respectivement enregistre la position du curseur puis restaure le curseur à la position précédemment enregistrée. Ces fonctions ne manipulent pas de pile, il est impossible d'enregistrer plus d'une seule position à la fois (le nouvel enregistrement écrasera l'ancien). Ainsi, nous allons écrire un texte, enregistrer la position du curseur, revenir en arrière et réécrire par dessus, pour enfin revenir à notre position précédente :

echo 'hello world';

// Save cursor position.
Hoa\Console\Cursor::save();
sleep(1);

// Go to the begining of the line.
Hoa\Console\Cursor::move('LEFT');
sleep(1);

// Replace “h” by “H”.
echo 'H';
sleep(1);

// Go to “w”.
Hoa\Console\Cursor::move('→', 5);
sleep(1);

// Replace “w” by “W”.
echo 'W';
sleep(1);

// Back to the saved position.
Hoa\Console\Cursor::restore();
sleep(1);

echo '!';

Le résultat final sera Hello World!. Nous remarquons qu'à chaque fois qu'un caractère est écrit, le curseur se déplace.

Affichage

Maintenant que le déplacement est acquis, nous allons voir comment nettoyer des lignes et/ou des colonnes. Pour cela, nous nous appuyons sur la méthode clear qui prend en argument les symboles suivants (concaténés par un espace) :

Ainsi, pour nettoyer toute une ligne :

Hoa\Console\Cursor::clear('↔');

Le curseur peut aussi agir comme un pinceau et ainsi écrire avec différentes couleurs ou différents styles grâce à la méthode colorize (nous pouvons tout mélanger en séparant chaque « commande » par des espaces). Commençons par énumérer les styles :

Ces styles sont très classiques. Passons maintenant aux couleurs. Tout d'abord, nous devons préciser si nous appliquons une couleur sur l'avant-plan du texte, soit le texte lui-même, ou alors sur son arrière-plan. Pour cela, nous allons nous aider respectivement de la syntaxe f[ore]g[round](color) et b[ack]g[round](color). La valeur de color peut être :

Les terminaux manipulent une des deux palettes : 8 couleurs ou 256 couleurs. Chaque couleur est indexée à partir de 0. Les noms des couleurs sont transformés vers leur index respectif. Quand une couleur est précisée en hexadécimal, elle est rapportée à la couleur la plus proche dans la palette comportant 256 couleurs.

Ainsi, si nous voulons écrire Hello en jaune sur fond presque rouge (#932e2e) et en plus souligné, puis  world mais non-souligné :

Hoa\Console\Cursor::colorize('fg(yellow) bg(#932e2e) underlined');
echo 'Hello';
Hoa\Console\Cursor::colorize('!underlined');
echo ' world';

Enfin, il est possible de modifier les palettes de couleurs grâce à la méthode changeColor, mais c'est à utiliser avec précaution, cela peut perturber l'utilisateur. Cette méthode prend en premier argument l'index de la couleur et en second argument sa valeur en hexadécimal. Par exemple, fg(yellow) correspond à l'index 33, et nous voulons que ce soit maintenant totalement bleu :

Hoa\Console\Cursor::changeColor(33, 0xf00);

Toutefois, la palette de 256 couleurs est suffisamment complète pour ne pas avoir besoin de modifier les couleurs.

Style

Le curseur n'est pas forcément toujours visible. Lors de certaines opérations, nous pouvons le cacher, effectuer nos déplacements, puis le rendre à nouveau visible. Les méthodes hide et show, toujours sur Hoa\Console\Cursor, sont là pour ça :

echo 'Visible', "\n";
sleep(5);

echo 'Invisible', "\n";
Hoa\Console\Cursor::hide();
sleep(5);

echo 'Visible', "\n";
Hoa\Console\Cursor::show();
sleep(5);

Il existe aussi trois types de curseurs, que nous pouvons choisir avec la méthode setStyle :

Cette méthode prend en second argument un booléen indiquant si le curseur doit clignoter (valeur par défaut) ou pas. Ainsi, nous allons faire tous les styles :

echo 'Block/steady: ';
Hoa\Console\Cursor::setStyle('▋', false);
sleep(3);

echo "\n", 'Vertical/blink: ';
Hoa\Console\Cursor::setStyle('|', true);
sleep(3);

// etc.

Souvent le curseur indique des zones ou éléments d'interactions différents, comme le pointeur de la souris.

Son

Le curseur est aussi capable d'émettre un petit « bip », souvent pour attirer l'attention de l'utilisateur. Nous allons utiliser la méthode éponyme bip :

Hoa\Console\Cursor::bip();

Il n'y a qu'une seule tonalité disponible.

Lecture en ligne

Une manière d'interagir avec les utilisateurs est de lire le flux STDIN, à savoir le flux d'entrée. Cette lecture est par défaut très basique : impossible d'effacer, impossible d'utiliser les flèches, impossible d'utiliser des raccourcis etc. C'est pourquoi il existe la « lecture en ligne », ou readline en anglais, qui reste une lecture sur le flux STDIN, mais plus évoluée. La bibliothèque Hoa\Console\Readline\Readline propose plusieurs fonctionnalités que nous allons décrire.

Usage basique

Pour lire une ligne (c'est à dire une entrée de l'utilisateur), nous allons instancier la classe Hoa\Console\Readline\Readline et appeler dessus la méthode readLine. Chaque appel de cette méthode va attendre que l'utilisateur saisisse une donnée puis appuye sur . À ce moment là, la méthode retournera la saisie de l'utilisateur (ou false s'il n'y a plus rien à lire). Cette méthode prend aussi en argument un préfixe, c'est à dire une donnée à afficher avant la saisie de la ligne. Il arrive que le terme prompt soit aussi utilisé dans la littérature, les deux notions sont identiques.

Ainsi, nous allons écrire un programme qui va lire les entrées de l'utilisateur et faire un écho. Le programme terminera si l'utilisateur saisit quit :

$rl = new Hoa\Console\Readline\Readline();

do {
    $line = $rl->readLine('> ');
    echo '< ', $line, "\n\n";
} while (false !== $line && 'quit' !== $line);

Maintenant, détaillons les services que nous offre Hoa\Console\Readline\Readline.

Nous sommes capables de nous déplacer (comprendre, déplacer le curseur) dans la ligne à l'aide des touches et . Nous pouvons à tout moment effacer un caractère en arrière avec la touche ou tous les caractères jusqu'au début du mot avec Ctrl + W (où W signifie word). Nous pouvons également nous déplacer avec des raccourcis claviers communs à beaucoup de logiciels :

Nous avons aussi accès à l'historique lorsque nous appuyons sur les touches et , respectivement pour chercher en arrière et avant dans l'historique. La touche déclenche l'auto-complétion si elle est définie. Et enfin, la touche retourne la saisie.

Il existe aussi la classe Hoa\Console\Readline\Password qui permet d'avoir un lecteur de lignes avec exactement les mêmes services mais les caractères ne s'impriment pas à l'écran, très utile pour lire un mot de passe :

$rl  = new Hoa\Console\Readline\Password();
$pwd = $rl->readLine('Password: ');

echo 'Your password is: ', $pwd, "\n";

Raccourcis

Pour comprendre comment créer des raccourcis, il faut un tout petit peu comprendre le fonctionnement interne de Hoa\Console\Readline\Readline, et il est très simple. À chaque fois que nous appuyons sur une ou plusieurs touches, une chaîne de caractères représentant cette combinaison est reçue par notre lecteur. Il regarde si une action est associée à cette chaîne : si oui, il l'exécute, si non, il en utilise une par défaut qui consiste à afficher la chaîne telle quelle. Chaque action retourne un état pour le lecteur (qui sont des constantes sur Hoa\Console\Readline\Readline) :

Ainsi, si une action retourne STATE_CONTINUE | STATE_NO_ECHO, la lecture continuera mais la chaîne qui vient d'être reçue ne sera pas affichée. Autre exemple, l'action associée à la touche retourne l'état STATE_BREAK.

Pour ajouter des actions, nous utilisons la méthode addMapping. Elle facilite l'ajout grâce à une syntaxe dédiée :

Par exemple, si nous voulons afficher z à la place de a, nous écrirons :

$rl->addMapping('a', 'z');

Plus compliqué maintenant, nous pouvons utiliser un callable en second paramètre de addMapping. Ce callable va recevoir l'instance de Hoa\Console\Readline\Readline en seul argument. Plusieurs méthodes sont là pour aider à manipuler le lecteur (gestion de l'historique, de la ligne etc.). Par exemple, à chaque fois que nous appuyerons sur Ctrl + R, nous inverserons la casse de la ligne :

$rl = new Hoa\Console\Readline\Readline();

// Add mapping.
$rl->addMapping('\C-R', function (Hoa\Console\Readline\Readline $self) {
    // Clear the line.
    Hoa\Console\Cursor::clear('↔');
    echo $self->getPrefix();

    // Get the line text.
    $line = $self->getLine();

    // New line.
    $new  = null;

    // Loop over all characters.
    for ($i = 0, $max = $self->getLineLength(); $i < $max; ++$i) {
        $char = mb_substr($line, $i, 1);

        if ($char === $lower = mb_strtolower($char)) {
            $new .= mb_strtoupper($char);
        } else {
            $new .= $lower;
        }
    }

    // Set the new line.
    $self->setLine($new);

    // Set the buffer (and let the readline echoes or not).
    $self->setBuffer($new);

    // The readline will continue to read.
    return $self::STATE_CONTINUE;
});

// Try!
var_dump($rl->readLine('> '));

Il ne faut pas hésiter à regarder comment sont implémentés les raccourcis précédemment énoncés pour se donner des idées.

Auto-complétion

Un outil également très utile lorsque nous écrivons un lecteur de lignes est l'auto-complétion. Elle se déclenche en appuyant sur la touche si un auto-compléteur a été défini à l'aide de la méthode setAutocompleter.

Tous les auto-compléteurs doivent implémenter l'interface Hoa\Console\Readline\Autocompleter\Autocompleter. Quelqu'uns sont déjà présents pour nous aider dans notre développement, comme Hoa\Console\Readline\Autocompleter\Word qui va auto-compléter la saisie à partir d'une liste de mots. Par exemple :

$rl = new Hoa\Console\Readline\Readline();
$rl->setAutocompleter(new Hoa\Console\Readline\Autocompleter\Word([
    'hoa',
    'console',
    'readline',
    'autocompleter',
    'autocompletion',
    'password',
    'awesome'
]));
var_dump($rl->readLine('> '));

Essayons d'écrire ce que nous voulons, puis où nous le souhaitons, appuyons sur . Si le texte à gauche du curseur commence par h, alors nous verrons hoa s'écrire d'un coup car l'auto-compléteur n'a pas de choix (il retourne une chaîne). Si l'auto-compléteur ne trouve aucun mot adapté, il ne se passera rien (il retournera null). Et enfin, s'il trouve plusieurs mots (il retournera un tableau), alors un menu s'affichera. Essayons d'auto-compléter simplement a : le menu proposera autocompleter, autocompletion et awesome. Soit nous continuons à taper et le menu va disparaître, soit nous pouvons nous déplacer dans le menu avec les touches , , , et , puis pour sélectionner un mot. Le comportement est assez naturel.

En plus de l'auto-compléteur sur les mots, nous trouvons un auto-compléteur sur les chemins avec la classe Hoa\Console\Readline\Autocompleter\Path. À partir d'une racine et d'un itérateur de fichiers, il est capable d'auto-compléter des chemins. Si la racine n'est pas précisée, le dossier courant sera utilisé. À chaque auto-complétion, une nouvelle instance de l'itérateur de fichiers est créée par une factory. Elle reçoit en seul argument le chemin à itérer. La factory par défaut est définie par la méthode statique getDefaultIteratorFactory sur Hoa\Console\Readline\Autocompleter\Path. Elle construit un itérateur de fichiers de type DirectoryIterator. Chaque valeur calculée par l'itérateur doit être un objet de type SplFileInfo. Ainsi, pour auto-compléter tous les fichiers et dossiers à partir de la racine hoa://Library/Console, nous écrirons :

$rl->setAutocompleter(
    new Hoa\Console\Readline\Autocompleter\Path(
        resolve('hoa://Library/Console')
    )
);

Utiliser une factory nous offre beaucoup de souplesse et nous permet d'utiliser n'importe quel itérateur de fichiers, comme par exemple Hoa\File\Finder (voir la bibliothèque Hoa\File). Ainsi, pour n'auto-compléter que les fichiers et dossiers non cachés qui ont été modifiés les 6 derniers mois triés par leur taille, nous écrirons :

$rl->setAutocompleter(
    new Hoa\Console\Readline\Autocompleter\Path(
        resolve('hoa://Library/Console'),
        function ($path) {
            $finder = new Hoa\File\Finder();
            $finder->in($path)
                   ->files()
                   ->directories()
                   ->maxDepth(1)
                   ->name('#^(?!\.).#')
                   ->modified('since 6 months')
                   ->sortBySize();

            return $finder;
        }
    )
);

Nous pouvons remplacer l'itérateur de fichiers locaux par un itérateur totalement différent : sur des fichiers stockés sur une autre machine, un service tiers ou même des ressources qui ne sont pas des fichiers mais ont des URI de la forme d'un chemin.

Enfin, nous pouvons assembler plusieurs auto-compléteurs entre eux grâce à la classe Hoa\Console\Readline\Autocompleter\Aggregate. L'ordre de déclaration des auto-compléteurs est important : le premier qui reconnaît un mot à auto-compléter prendra la main. Ainsi, pour auto-compléter des chemins et des mots, nous écrirons :

$rl->setAutocompleter(
    new Hoa\Console\Readline\Autocompleter\Aggregate([
        new Hoa\Console\Readline\Autocompleter\Path(),
        new Hoa\Console\Readline\Autocompleter\Word($words)
    ])
);

La méthode getAutocompleters de Hoa\Console\Readline\Autocompleter\Aggregate retourne un objet ArrayObject pour plus de souplesse. Nous pouvons ainsi toujours ajouter ou supprimer des auto-compléteurs après les avoir déclarés dans le constructeur.

Exemple d'une agrégation de l'auto-compléteur Hoa\Console\Readline\Autocompleter\Path avec Hoa\Console\Readline\Autocompleter\Word.

Lecture d'options

Une grande force des programmes en ligne de commande est leur flexibilité. Ils sont dédiés à une seule (petite) tâche et nous pouvons les paramétrer grâce aux options qu'ils exposent. La lecture de ces options doit être simple et rapide car c'est une tâche répétitive et délicate. La classe Hoa\Console\Parser et Hoa\Console\GetOption fonctionnent en duo afin de répondre à cette problématique.

Analyser les options

Nous allons commencer par utiliser Hoa\Console\Parser qui permet d'analyser les options données à un programme. Peu importe les options que nous voulons précisément, nous nous contentons de les analyser pour l'instant. Commençons par utiliser la méthode parse :

$parser = new Hoa\Console\Parser();
$parser->parse('-s --long=value input');

print_r($parser->getSwitches());
print_r($parser->getInputs());

/**
 * Will output:
 *     Array
 *     (
 *         [s] => 1
 *         [long] => value
 *     )
 *     Array
 *     (
 *         [0] => input
 *     )
 */

Étudions un peu de quoi est constituée une ligne de commande. Nous avons deux catégories : les options (switches) et les entrées (inputs). Les entrées sont tout ce qui n'est pas une option. Une option peut avoir deux formes : courte si elle n'a qu'un seul caractère ou longue si elle en a plusieurs.

Ainsi, -s est une option courte, et --long est une option longue. Toutefois, il faut aussi considérer le nombre de tirets devant l'option : avec deux tirets, ce sera toujours une option longue, avec un seul tiret, ça dépend. Il y a deux écoles qui se différencient avec un seul paramètre : long only. Prenons un exemple : -abc est considéré comme -a -b -c si le paramètre long only est définie à false, sinon ce sera équivalent à une option longue, comme --abc. Majoritairement, ce paramètre est définie à false par défaut et Hoa\Console\Parser s'est rangé du côté de la majorité. Pour modifier ce paramètre, il faut utiliser la méthode setLongOnly, voyons plutôt :

// long only is set to false.
$parser->parse('-abc');
print_r($parser->getSwitches());

$parser->setLongOnly(true);

// long only is set to true.
$parser->parse('-abc');
print_r($parser->getSwitches());

/**
 * Will output:
 *     Array
 *     (
 *         [a] => 1
 *         [b] => 1
 *         [c] => 1
 *     )
 *     Array
 *     (
 *         [abc] => 1
 *     )
 */

Une option peut être de deux sortes : booléenne ou valuée. Si aucune valeur ne lui est associée, elle est considérée comme booléenne. Ainsi, -s vaut true, mais -s -s vaut false, et du coup -s -s -s vaut true et ainsi de suite. Une option booléenne fonctionne comme un interrupteur. Une option valuée a une valeur associée, soit par un espace, soit par un signe d'égalité (symbole =). Voici une liste non-exhaustive des possibilités avec la valeur associée (nous utilisons une option courte mais ça peut être une option longue) :

Les simples (symbole ') et doubles (symbole ") guillemets sont supportés. Mais attention, il y a des cas particuliers qui ne sont pas toujours standards :

À l'instar des options booléennes qui fonctionnent comme des interrupteurs, les options valuées réécrivent leurs valeurs si elles sont déclarées plusieurs fois. Ainsi avec -a=b -a=c, -a vaudra c.

Enfin, il y a des valeurs qui sont considérées comme spéciales. Nous en distingons deux :

Sans aucune manipulation, ces valeurs ne seront pas considérées comme spéciales. Il faudra utiliser la méthode Hoa\Console\Parser::parseSpecialValue comme nous allons le voir très prochainement.

Lire les options et les entrées

Nous savons analyser les options mais ce n'est pas suffisant pour les lire correctement. Il faut leur donner une petite sémantique : qu'attendent-elles, quelle est leur nature etc. Pour cela, nous allons nous aider de la classe Hoa\Console\GetOption. Une option est caractérisée par :

Ces trois informations sont obligatoires. Elles doivent être données au constructeur de Hoa\Console\GetOption en premier argument. Le second argument est l'analyseur d'options (l'analyse doit être préalablement effectuée). Ainsi nous décrivons deux options : extract qui est une option booléenne, et directory qui est une option valuée :

$parser = new Hoa\Console\Parser();
$parser->parse('-x --directory=value inputA inputB inputC');

$options = new Hoa\Console\GetOption(
    [
        // long name                  type                  short name
        //   ↓                         ↓                         ↓
        ['extract',   Hoa\Console\GetOption::NO_ARGUMENT,       'x'],
        ['directory', Hoa\Console\GetOption::REQUIRED_ARGUMENT, 'd']
    ],
    $parser
);

Nous pouvons maintenant lire nos options ! Le lecteur d'options fonctionne comme un itérateur, ou plutôt une pipette, à l'aide de la méthode getOption. Cette méthode retourne le nom court de l'option lue et assignera la valeur de l'option (un booléen ou une chaîne de caractères) à son premier argument passé en référence. Quand la pipette est vide, la méthode getOption retourne false. Cette structure peut paraître originale mais elle est pourtant très répandue, vous ne serez pas déroutés en la voyant autre part (exemples dans Linux, dans FreeBSD ou dans Mac OS X — même base de code —). La manière la plus simple pour lire les options est de définir des valeurs par défaut pour nos options, puis d'utiliser getOption, ainsi :

$extract   = false;
$directory = '.';

//          short name                  value
//               ↓                        ↓
while (false !== $c = $options->getOption($v)) {
    switch($c) {
        case 'x':
            $extract = $v;

            break;

        case 'd':
            $directory = $v;

            break;
    }
}

var_dump($extract, $directory);

/**
 * Will output:
 *     bool(true)
 *     string(5) "value"
 */

Cela se lit : « tant que nous avons une option à lire, nous récupérons son nom court dans $c et sa valeur dans $v, puis nous regardons quoi en faire ».

Pour lire les entrées, nous utiliserons la méthode Hoa\Console\Parser::listInputs dont tous les arguments (au nombre de 26) sont passés en référence. Ainsi :

$parser->listInputs($inputA, $inputB, $inputC);

var_dump($inputA, $inputB, $inputC);

/**
 * Will output:
 *     string(6) "inputA"
 *     string(6) "inputB"
 *     string(6) "inputC"
 */

Attention, cette façon de procéder implique que les entrées sont ordonnées (comme c'est pratiquement toujours le cas). Mais aussi, lire les entrées sans avoir préalablement donné l'analyseur à Hoa\Console\GetOption peut produire des résultats imprévus (car par défaut, toutes les options sont considérées comme booléennes). Si nous voulons toutes les entrées et les analyser manuellement si elles ne sont pas ordonnées, nous pouvons utiliser la méthode Hoa\Console\Parser::getInputs qui retournera toutes les entrées.

Options spéciales ou ambiguës

Revenons sur la méthode Hoa\Console\Parser::parseSpecialValue. Elle prend deux arguments : une valeur et un tableau de mots-clés. Voyons plutôt. Nous reprenons notre exemple et modifions le cas pour l'option d :

while (false !== $c = $options->getOption($v)) {
    switch($c) {
        case 'x':
            $extract = $v;

            break;

        case 'd':
            $directory = $parser->parseSpecialValue($v, ['HOME' => '/tmp']);

            break;
    }
}

print_r($directory);

Si nous essayons avec -d=a,b,HOME,c,d, alors -d aura la valeur suivante :

/**
 * Array
 * (
 *     [0] => a
 *     [1] => b
 *     [2] => /tmp
 *     [3] => c
 *     [4] => d
 * )
 */

Enfin, quand une option lue n'existe pas mais qu'elle est très proche d'une option existante à quelques fautes près (par exemple --dirzctory au lieu de --directory), nous pouvons utiliser le cas __ambiguous pour la capturer et la traiter :

while (false !== $c = $options->getOption($v)) {
    switch($c) {
        case 'x':
            $extract = $v;

            break;

        case 'd':
            $directory = $parser->parseSpecialValue($v, ['HOME' => '/tmp']);

            break;

        case '__ambiguous':
            print_r($v);

            break;
    }
}

La valeur (dans $v) est un tableau avec trois entrées. Par exemple avec --dirzctory, nous obtenons :

/**
 * Array
 * (
 *     [solutions] => Array
 *         (
 *             [0] => directory
 *         )
 *
 *     [value] => y
 *     [option] => dirzctory
 * )
 */

La clé solutions propose toutes les options similaires, la clé value donne la valeur de l'option et option le nom original lu. C'est à l'utilisateur de décider quoi faire à partir de ces informations. Nous pouvons utiliser la méthode Hoa\Console\GetOption::resolveOptionAmbiguity en lui donnant ce tableau, et elle choisira la meilleure option si elle existe :

    case '__ambiguous':
        $options->resolveOptionAmbiguity($v);

        break;

Il est quand même préférable d'avertir l'utilisateur qu'il y a une ambiguïté et de lui demander son avis. Il peut parfois être dangereux de prendre la décision à sa place.

Intégrer un routeur et un dispatcheur

Jusqu'à maintenant, nous forcions des options et des entrées à l'analyseur. Hoa\Router\Cli permet d'extraire des données depuis un programme en ligne de commande. Une méthode nous intéresse : Hoa\Router\Cli::getURI, qui va nous donner toutes les options et les entrées du programme courant, que nous pourrons alors fournir à notre analyseur. Ainsi :

$parser = new Hoa\Console\Parser();
$parser->parse(Hoa\Router\Cli::getURI());

// …

Il est maintenant possible d'interpréter les options que nous donnons à notre propre programme. Si vous avez écrit les tests dans un fichier nommé Test.php, alors vous pourrez écrire :

$ php Test.php -x -d=a,b,HOME,c,d inputA inputB
bool(true)
Array
(
    [0] => a
    [1] => b
    [2] => /tmp
    [3] => c
    [4] => d
)
string(6) "inputA"
string(6) "inputB"
NULL

L'option -x vaut bien true, l'option -d vaut un tableau (car nous l'avons analysé avec la méthode Hoa\Console\Parser::parseSpecialValue), et nous avons inputA, inputB et null en entrée.

C'est un bon début, et nous pourrions nous arrêter là dans la plupart des cas. Mais il est possible d'aller plus loin en mettant en place un dispatcheur : écrire des commandes dans plusieurs fonctions ou classes et les appeler en fonction des options et entrées données à notre programme. Nous vous conseillons de regarder le code source de hoa://Library/Cli/Bin/Hoa.php pour vous aider, ainsi que les chapitres de Hoa\Router et Hoa\Dispatcher. Nous proposons un exemple rapide sans donner trop de détails sur les bibliothèques précédement citées.

L'idée est la suivante. Grâce à Hoa\Router\Cli, nous allons extraire des données de la forme suivante : $ php script.php controller tail, où controller sera le nom du contrôleur (d'une classe) sur laquelle nous appellerons l'action main (soit la méthode main avec les paramètres par défaut), et où tail correspond aux options et aux entrées. Le nom du contrôleur est identifié par la variable spéciale _call (au niveau de Hoa\Router\Cli) et les options ainsi que les entrées par _tail (au niveau de Hoa\Dispatcher\Kit). Les options et entrées ne sont pas obligatoires. Ensuite, nous allons utiliser Hoa\Dispatcher\Basic avec le kit dédié aux terminaux, à savoir Hoa\Console\Dispatcher\Kit. Le dispatcheur va chercher à charger les classes Application\Controller\controller par défaut, et l'auto-chargeur va les chercher dans le dossier hoa://Application/Controller/controller. Nous allons donc préciser où se trouve l'application très rapidement. Enfin, le code de retour de notre programme sera donné par la valeur de retour de notre contrôleur et de notre action. En cas d'erreur, nous l'afficherons et nous forcerons un code de retour supérieur à zéro. Ainsi :

try {
    // Prepare the router.
    $router = new Hoa\Router\Cli();
    $router->get(
        'g',
        '(?<_call>\w+)(?:\s+(?<_tail>.+))?'
    );

    // Prepare the dispatcher.
    $dispatcher = new Hoa\Dispatcher\ClassMethod([
        'synchronous.call' => 'Application\Controller\(:call:U:)',
        'synchronous.able' => 'main'
    ]);
    $dispatcher->setKitName('Hoa\Console\Dispatcher\Kit');

    // Dispatch!
    exit($dispatcher->dispatch($router));
} catch (Hoa\Exception $e) {
    echo $e->raise(true);
    exit($e->getCode() + 1);
}

Au même niveau que notre programme, créons le dossier Application/Controller/ avec le fichier Foo.php à l'intérieur, qui contiendra le code suivant :

<?php

namespace Application\Controller;

class Foo extends \Hoa\Console\Dispatcher\Kit
{
    protected $options = [
        ['extract',   \Hoa\Console\GetOption::NO_ARGUMENT,       'x'],
        ['directory', \Hoa\Console\GetOption::REQUIRED_ARGUMENT, 'd'],
        ['help',      \Hoa\Console\GetOption::NO_ARGUMENT,       'h']
    ];

    public function MainAction()
    {
        $extract   = false;
        $directory = '.';

        while (false !== $c = $this->getOption($v)) {
            switch($c) {
                case 'x':
                    $extract = $v;

                    break;

                case 'd':
                    $directory = $this->parser->parseSpecialValue($v, ['HOME' => '/tmp']);

                    break;

                case 'h':
                    return $this->usage();
            }
        }

        echo 'extract:   ';
        var_dump($extract);
        echo 'directory: ';
        print_r($directory);

        return;
    }

    public function usage()
    {
        echo
            'Usage   : foo <options>', "\n",
            'Options :', "\n",
            $this->makeUsageOptionsList([
                'x' => 'Whether we need to extract.',
                'd' => 'Directory to extract.',
                'h' => 'This help.'
            ]);
    }
}

Notre classe étend bien notre kit pour bénéficier des méthodes qu'il propose. Entre autre, sa propre méthode getOption, qui va exploiter l'attribut $options où sont déclarées les options, makeUsageOptionsList pour afficher une aide, sa propre méthode resolveOptionAmbiguity qui demande une confirmation à l'utilisateur, l'accès au routeur à travers l'attribut $router etc. Les kits offrent des services à l'application, ils aggrègent des services offerts par les bibliothèques. Maintenant testons :

$ php Test.php foo -x -d=1:3
extract:   bool(true)
directory: Array
(
    [0] => 1
    [1] => 2
    [2] => 3
)

Magnifique !

Précisons que le script hoa est exactement construit de cette manière. N'hésitez pas à vous en inspirer.

Processus

Dans notre contexte, un processus est un programme classique qui s'exécute dans un terminal. Ce qui est intéressant, c'est qu'un tel programme communique avec le reste de son environnement grâce à des tuyaux, ou pipes en anglais, numérotés à partir de zéro. Certains ont même des noms et sont standards :

Quand un processus s'exécute dans un terminal, STDIN utilise le clavier comme source de données, et STDOUT comme STDERR sont reliés à la fenêtre d'un terminal. Mais quand un processus est exécuté dans un sous-terminal, c'est à dire exécuté à partir d'un autre processus, STDIN n'est pas relié au clavier, tout comme STDOUT et STDERR ne sont pas reliés à l'écran. C'est le processus parent qui va écrire et lire sur ces flux pour interagir avec le « sous »-processus. Ce mécanisme s'appelle la redirection de flux, nous l'utilisons très souvent quand nous écrivons une ligne de commande (voir section Redirections du Bash Reference Manual). Ce que nous allons faire utilise une autre syntaxe mais le mécanisme est le même.

Il est très important de savoir que ces flux sont tous asynchrones les uns par rapport aux autres. Aucun flux n'a un impact sur un autre, il n'y a aucun lien entre eux et c'est important pour la suite.

Au niveau de PHP, il est possible d'accéder à ces flux en utilisant respectivement les URI suivants : php://stdin, php://stdout et php://stderr. Mais nous avons aussi les constantes éponymes STDIN, STDOUT et STDERR. Elles sont définies comme suit (exemple avec STDIN) :

define('STDIN', fopen('php://stdin', 'r'));

Ces flux ne sont disponibles que si le programme s'exécute en ligne de commande. Rappelons-nous également que les pipes sont identifiés par des numéros. Nous pouvons alors utiliser php://fd/0 pour se référer à STDIN, php://fd/1 pour STDOUT etc. L'URI php://fd/i permet d'accéder au fichier ayant le descripteur i.

Exécution très basique

La classe Hoa\Console\Processus propose une manière très rapide d'exécuter un processus et d'obtenir le résultat de STDOUT. C'est le cas le plus commun. Ainsi, nous allons utiliser la méthode statique execute :

var_dump(Hoa\Console\Processus::execute('id -u -n'));

/**
 * Could output:
 *     string(3) "hoa"
 */

Par défaut, la commande sera échappée pour des raisons de sécurité. Si vous avez confiance dans la commande, vous pouvez désactiver l'échappement en passant false en second argument.

Nous n'avons aucun contrôle sur les pipes et même si ça convient dans la plupart des cas, ce n'est pas suffisant quand nous souhaitons un minimum d'interaction avec le processus.

Lecture et écriture

Voyons comment interagir avec un processus. Nous allons considérer le programme LittleProcessus.php suivant :

<?php

$range = range('a', 'z');

while (false !== $line = fgets(STDIN)) {
    echo '> ', $range[intval($line)], "\n";
}

Pour tester et comprendre son fonctionnement, écrivons la ligne de commande suivante et entrons au clavier 3, puis 4 :

$ php LittleProcessus.php
3
> d
4
> e

Nous pouvons aussi écrire :

$ seq 0 4 | php LittleProcessus.php
> a
> b
> c
> d
> e

Notre programme va lire chaque ligne sur l'entrée standard, considérer que c'est un nombre, et le transformer en caractère qui sera affiché sur la sortie standard. Nous aimerions exécuter ce programme en lui donnant nous-même une liste de nombres (comme le programme seq) et en observant le résultat qu'il produira.

Une instance de la classe Hoa\Console\Processus représente un processus. Lors de l'instanciation, nous devons préciser :

Il y a d'autres arguments mais nous les verrons plus tard.

La description des pipes a la forme d'un tableau où chaque clé représente le numéro du pipe (plus généralement, c'est le i de php://fd/i) et la valeur est encore un tableau décrivant la nature du pipe, soit un « vrai » pipe, soit un fichier, avec leur mode de lecture ou d'écriture (parmi r, w ou a). Illustrons avec un exemple :

$processus = new Hoa\Console\Processus(
    'php',
    ['LittleProcessus.php'],
    [
        // STDIN.
        0 => ['pipe', 'r'],
        // STDOUT.
        1 => ['file', '/tmp/output', 'a']
    ]
);

Dans ce cas, STDIN est un pipe et STDOUT est le fichier /tmp/output. Si nous ne précisions pas de descripteur, ce sera équivalent à écrire :

$processus = new Hoa\Console\Processus(
    'php',
    ['LittleProcessus.php'],
    [
        // STDIN.
        0 => ['pipe', 'r'],
        // STDOUT.
        1 => ['pipe', 'w'],
        // STDERR.
        2 => ['pipe', 'w']
    ]
);

Chaque pipe est reconnu comme un flux et peut être manipulé comme tel. Quand un pipe est en lecture (avec le mode r), cela signifie que le processus va lire dessus. Donc nous, le processus parent, nous allons écrire sur ce pipe. Prenons l'exemple de STDIN : le processus lit sur STDIN ce que le clavier a écrit dessus. Et inversement, quand un pipe est en écriture (avec le mode w), cela signifie que nous allons lire dessus. Prenons l'exemple de STDOUT : l'écran va lire ce que le processus lui a écrit.

La classe Hoa\Console\Processus étend la classe Hoa\Stream, et de ce fait, nous avons tous les outils nécessaires pour lire et écrire sur les pipes de notre choix. Cette classe propose aussi plusieurs écouteurs :

Prenons directement un exemple. Nous allons exécuter le processus php LittleProcessus.php et attacher des fonctions aux écouteurs suivants : input pour écrire une série de chiffres et output pour lire le résultat.

$processus = new Hoa\Console\Processus('php LittleProcessus.php');
$processus->on('input', function ($bucket) {
    $source = $bucket->getSource();
    $data   = $bucket->getData();

    echo 'INPUT (', $data['pipe'], ')', "\n";

    $source->writeAll(
        implode("\n", range($i = mt_rand(0, 21), $i + 4)) . "\n"
    );

    return false;
});
$processus->on('output', function ($bucket) {
    $data = $bucket->getData();

    echo 'OUTPUT (', $data['pipe'], ') ', $data['line'], "\n";

    return;
});
$processus->run();

/**
 * Could output:
 *     INPUT (0)
 *     OUTPUT (1) > s
 *     OUTPUT (1) > t
 *     OUTPUT (1) > u
 *     OUTPUT (1) > v
 *     OUTPUT (1) > w
 */

Maintenant, rentrons dans le détail pour bien comprendre les choses.

Quand un flux en lecture est prêt, alors l'écouteur input se déclenche. Une seule donnée est envoyée : pipe qui contient le numéro du pipe (le i de php://fd/i). Quand un flux en écriture est prêt, alors l'écouteur output se déclenche. Deux données sont envoyées : pipe (comme pour input) et line qui est la ligne reçue.

Nous voyons dans la fonction attachée à l'écouteur input que nous écrivons une suite de nombres concaténés par \n (un nombre par ligne). Pour cela, nous utilisons la méthode writeAll. Par défaut, les méthodes d'écriture écrivent sur le pipe 0. Pour changer ce comportement, il faudra donner le numéro de pipe en second argument des méthodes d'écriture. Pareil pour les méthodes de lecture mais le pipe par défaut est 1.

Quand un callable attaché à un écouteur retourne false, le pipe qui a déclenché cet appel sera fermé juste après. Dans notre cas, la fonction attachée à input retourne false juste après avoir écrit, nous n'avons plus besoin de ce pipe. Il est important pour des raisons de performances de fermer les pipes dès que possible.

Enfin, pour exécuter le processus, nous utilisons la méthode Hoa\Console\Processus::run d'arité nulle.

Dans notre exemple, nous écrivons toutes les données d'un coup mais nous pouvons envoyer les données dès qu'elles sont disponibles, ce qui est plus performant car le processus n'attend pas un gros paquet de données : il peut les traiter au fur et à mesure. Modifions notre exemple pour écrire une donnée à chaque fois que STDIN est prêt :

$processus->on('input', function ($bucket) {
    static $i = null;
    static $j = 5;

    if (null === $i) {
        $i = mt_rand(0, 20);
    }

    $data = $bucket->getData();

    echo 'INPUT (', $data['pipe'],')', "\n";

    $source = $bucket->getSource();
    $source->writeLine($i++);
    usleep(50000);

    if (0 >= $j--) {
        return false;
    }

    return;
});

Nous initialisons deux variables : $i et $j, qui portent le nombre à envoyer et le nombre maximum de données à envoyer. Nous introduisons une latence volontaire avec usleep(50000) pour laisser le temps à STDOUT d'être prêt, ceci afin de mieux illustrer notre exemple. Dans ce cas, la sortie serait :

/** Could output:
 *     INPUT (0)
 *     OUTPUT (1) > h
 *     INPUT (0)
 *     OUTPUT (1) > i
 *     INPUT (0)
 *     OUTPUT (1) > j
 *     INPUT (0)
 *     OUTPUT (1) > k
 *     INPUT (0)
 *     OUTPUT (1) > l
 *     INPUT (0)
 *     OUTPUT (1) > m
 */

Le processus est en attente d'une entrée et lit les données dès qu'elles arrivent. Une fois que nous avons envoyé toutes les données, nous fermons le pipe.

Le processus se ferme de lui-même. Nous avons la méthode Hoa\Console\Processus::getExitCode pour connaître le code de retour du processus. Attention, un code 0 représente un succès. Comme l'erreur est répandue, il existe la méthode Hoa\Console\Processus::isSuccessful pour savoir si le processus s'est exécuté avec succès ou pas.

Détecter le type des pipes

Parfois, il est utile de connaître le type des pipes, c'est à dire si c'est une utilisation directe, un pipe ou une redirection. Nous allons nous aider de la classe Hoa\Console\Console et de ses méthodes statiques isDirect, isPipe et isRedirection pour obtenir ces informations.

Prenons un exemple pour comprendre plus rapidement. Écrivons le fichier Type.php qui va étudier le type de STDOUT :

echo 'is direct:      ';
var_dump(Hoa\Console\Console::isDirect(STDOUT));

echo 'is pipe:        ';
var_dump(Hoa\Console\Console::isPipe(STDOUT));

echo 'is redirection: ';
var_dump(Hoa\Console\Console::isRedirection(STDOUT));

Et maintenant, exécutons ce fichier pour voir le résultat :

$ php Type.php
is direct:      bool(true)
is pipe:        bool(false)
is redirection: bool(false)

$ php Type.php | xargs -I@ echo @
is direct:      bool(false)
is pipe:        bool(true)
is redirection: bool(false)

$ php Type.php > /tmp/foo; cat /tmp/foo
is direct:      bool(false)
is pipe:        bool(false)
is redirection: bool(true)

Dans le premier cas, STDOUT est bien direct (pour STDOUT, cela signifie qu'il est relié à l'écran, pour STDIN, il serait relié au clavier etc.). Dans le deuxième cas, STDOUT est un pipe, c'est à dire qu'il est attaché au STDIN de la commande située après le symbole |. Dans le dernier cas, STDOUT est une redirection, c'est à dire qu'il est redirigé dans le fichier /tmp/foo (que nous affichons juste après). L'opération peut se faire sur STDIN, STDERR ou n'importe quelle autre ressource.

Connaître le type des pipes peut permettre des comportements différents selon le contexte. Par exemple, Hoa\Console\Readline\Readline lit sur STDIN. Si son type est un pipe ou une redirection, le mode d'édition de ligne avancé sera désactivé et il retourne false quand il n'a plus rien à lire. Autre exemple, la verbosité des commandes du script hoa utilise le type de STDOUT comme valeur par défaut : direct pour être verbeux, sinon non-verbeux. Essayez les exemples suivants pour voir la différence :

$ hoa --no-verbose
$ hoa | xargs -I@ echo @

Les exemples ne manquent pas mais attention à utiliser cette fonctionnalité avec intelligence. Il faut adapter les comportements mais rester cohérent.

Condition d'exécution

Le processus s'exécute dans un dossier particulier et un environnement particulier. Le dossier est appelé current working directory, souvent abrégé cwd. Il définit le dossier où sera exécuté le processus. Nous pouvons le retrouver en PHP avec la fonction getcwd. L'environnement se définit par un tableau que nous retrouvons par exemple en exécutant /usr/bin/env. C'est dans cet environnement qu'est présent le PATH par exemple. Ces données sont passées en quatrième et cinquième arguments du constructeur de Hoa\Console\Processus. Ainsi :

$processus = new Hoa\Console\Processus(
    'php',
    null, /* no option         */
    null, /* use default pipes */
    '/tmp',
    [
        'FOO'  => 'bar',
        'BAZ'  => 'qux',
        'PATH' => '/usr/bin:/bin'
    ]
);
$processus->on('input', function (Hoa\Event\Bucket $bucket) {
    $bucket->getSource()->writeAll(
        '<?php' . "\n" .
        'var_dump(getcwd());' . "\n" .
        'print_r($_ENV);'
    );

    return false;
});
$processus->on('output', function (Hoa\Event\Bucket $bucket) {
    $data = $bucket->getData();
    echo '> ', $data['line'], "\n";

    return;
});
$processus->run();

/**
 * Will output:
 *     > string(12) "/tmp"
 *     > Array
 *     > (
 *     >     [FOO] => bar
 *     >     [PATH] => /usr/bin:/bin
 *     >     [PWD] => /tmp
 *     >     [BAZ] => qux
 *     >     [_] => /usr/bin/php
 *     >
 *     > )
 */

Si le current working directory n'est pas précisé, nous utiliserons le même que le programme. Si aucun environnement n'est précisé, le processus utilisera celui de son parent.

Nous pouvons aussi imposer un temps maximum de réponse en seconde au processus (défini à 30 secondes par défaut). C'est le dernier argument du constructeur. Nous pouvons utiliser la méthode Hoa\Console\Processus::setTimeout. Pour savoir quand ce temps est atteint, nous devons utiliser l'écouteur timeout. Aucune action ne sera faite automatiquement. Nous pouvons par exemple terminer le processus grâce à la méthode Hoa\Console\Processus::terminate. Ainsi :

$processus = new Hoa\Console\Processus('php');

// 3 seconds is enough…
$processus->setTimeout(3);

// Sleep 10 seconds.
$processus->on('input', function (Hoa\Event\Bucket $bucket) {
    $bucket->getSource()->writeAll('<?php sleep(10);');

    return false;
});

// Terminate the processus on timeout.
$processus->on('timeout', function (Hoa\Event\Bucket $bucket) {
    echo 'TIMEOUT, terminate', "\n";
    $bucket->getSource()->terminate();

    return;
});

$processus->run();

/**
 * Will output (after 3 secondes):
 *     TIMEOUT, terminate
 */

Aucun action n'est réalisée automatiquement car elles peuvent être nombreuses. Nous pouvons peut-être débloquer le processus, le fermer pour en ouvrir un autre, émettre des rapports etc.

À propos de la méthode terminate, elle peut prendre plusieurs valeurs différentes, définies par les constantes de Hoa\Console\Processus : SIGHUP, SIGINT, SIGQUIT, SIGABRT, SIGKILL, SIGALRM et SIGTERM (par défaut). Plusieurs signaux peuvent être envoyés aux processus pour qu'ils s'arrêtent. Pour avoir le détail, voir la page signal.

Miscellaneous

Les méthodes statiques getTitle et setTitle sur la classe Hoa\Console\Processus permettent respectivement d'obtenir et de définir le titre du processus. Ainsi :

Hoa\Console\Processus::setTitle('hoa #1');

Et dans un autre terminal :

$ ps | grep hoa
69578 ttys006    0:00.01 hoa #1
70874 ttys008    0:00.00 grep hoa

Ces méthodes sont très pratiques lorsque nous manipulons beaucoup de processus et que nous voulons les identifier efficacement (par exemple avec des outils comme top ou ps). Notons qu'elles ne sont fonctionnelles que si vous avez PHP5.5 au minimum.

Une autre méthode statique intéressante est Hoa\Console\Processus::locate qui permet de déterminer le chemin vers un programme. Par exemple :

var_dump(Hoa\Console\Processus::locate('php'));

/**
 * Could output:
 *     string(12) "/usr/bin/php"
 */

Dans le cas où le programme n'est pas trouvé, null sera retournée. Cette méthode se base sur le PATH de votre système.

Processus interactifs et pseudo-terminaux

Cette section est un peu plus technique mais explique un problème qui peut arriver avec certains processus dits interactifs.

La classe Hoa\Console\Processus permet d'automatiser l'interaction avec des processus très facilement. Toutefois, ce n'est pas toujours possible de créer cette automatisation, à cause du comportement du processus. Nous allons illustrer le problème en écrivant le fichier Interactive.php :

<?php

echo 'Login: ';

if (false === $login = fgets(STDIN)) {
    fwrite(STDERR, 'Hmm, no login.' . "\n");
    exit(1);
}

echo 'Password: ';

if (false === $password = fgets(STDIN)) {
    fwrite(STDERR, 'Hmm, no password.' . "\n");
    exit(2);
}

echo 'Result:', "\n\t", $login, "\t", $password;

Exécutons ce processus pour voir ce qu'il fait :

$ php Interactive.php
Login: myLogin
Password: myPassword
Result:
    myLogin
    myPassword

Et maintenant, automatisons l'exécution de ce processus :

$ echo 'myLogin\nmyPassword' > data
$ php Interactive.php < data
Login: Password: Result:
    myLogin
    myPassword

Excellent. Nous pourrions avoir le même résultat avec Hoa\Console\Processus sans problème. Maintenant, si notre processus veut s'assurer que STDIN est vide entre deux entrées, il peut ajouter :

}

fseek(STDIN, 0, SEEK_END);

echo 'Password: ';

Et alors dans ce cas, si nous essayons d'automatiser l'exécution :

$ php Interactive.php < data
Login: Password: Hmm, no password.

C'est un comportement tout à fait normal, mais Hoa\Console\Processus ne peut rien faire pour remédier à ce problème.

La solution serait d'utiliser un pseudo-terminal en utilisant les fonctions PTY (voir dans Linux ou dans FreeBSD). Malheureusement ces fonctions ne sont pas disponibles dans PHP pour des raisons techniques. Il n'y a pas de solution possible en PHP pur, mais il est toujours envisageable d'utiliser un programme externe, écrit par exemple en C.

Conclusion

La bibliothèque Hoa\Console offre des outils complets pour écrire des programmes adaptés à une interface textuelle, que ce soit l'interaction avec la fenêtre ou le curseur, l'interaction avec l'utilisateur grâce à un lecteur de lignes très personnalisable (avec de l'auto-complétion ou des raccourcis), la lecture d'options pour les programmes eux-mêmes, la construction de programmes élaborés, ou encore l'exécution, l'interaction et la communication avec des processus.

Une erreur ou une suggestion sur la documentation ? Les contributions sont ouvertes !

Comments

menu