<?php
namespace nur\cli;

use nur\A;
use nur\akey;
use nur\b\IllegalAccessException;
use nur\func;
use nur\md;
use nur\oprop;
use nur\ref\ref_args;
use nur\str;
use nur\types;
use nur\valx;
use stdClass;

/**
 * Class ArgsParser: analyse d'arguments de ligne de commande
 *
 * == définition des arguments ==
 *
 * Les arguments valides sont décrits avec un tableau de définitions, qui peut
 * contenir à la fois des clés associatives et des clés séquentielles.
 *
 * Les clés associatives doivent être conformes au schéma ARGS_SCHEMA et servent
 * à donner des méta-informations sur les options et arguments valides.
 *
 * Les clés séquentielles sont chacune la description d'une option ou d'une
 * commande.
 *
 * il est possible de grouper des options ensemble en définissant des sections.
 * chaque section doit être conforme au schéma SECTION_SCHEMA.
 *
 * == définitions des options et commandes ==
 *
 * Chaque option ou commande est décrite par un tableau, qui peut contenir à la
 * fois des clés associatives et des clés séquentielles.
 *
 * S'il n'y a aucune clé séquentielle dans une définition, alors la définition
 * concerne la prise en charge des arguments restants sur la ligne de commande
 * Sinon, les clés séquentielles définissent toutes les options possibles e.g
 * '-s', '--long' ou 'command'
 * Toutes les options d'une même définition sont strictement équivalentes
 *
 * Les clés associatives d'une définition doivent être conformes à OPTION_SCHEMA
 * et décrivent l'option ou la commande:
 *
 * Si une option est sans argument, l'action par défaut est d'incrémenter la
 * destination. Avec "inverse", on décrémente la destination (mais la valeur
 * ne peux pas être inférieure à 0). Avec "value", on fixe la valeur.
 *
 * Si on ne fournit pas le nom de la propriété ou de la clé de destination,
 * elle est dérivée à partir du nom de l'option longue la plus longue. Les
 * caractères autre qu'alphanumériques sont remplacés par '_'. Si le nom final
 * commence par un chiffre, la lettre 'p' est rajoutée en préfixe. Ainsi, la
 * propriété par défaut pour l'option '--123' est 'p123'
 *
 * :: args
 * chaque argument de "args" est le type l'argument attendu par l'option.
 *
 * Un tableau indique que les arguments sont facultatifs. La valeur null indique
 * que le nombre d'arguments facultatifs est non borné. Le type de ces arguments
 * facultatifs est celui de l'argument précédent.
 *
 * Sur la ligne de commande, pour arrêter la saisie des arguments facultatifs,
 * il suffit d'utiliser '--'
 *
 * Par exemple, ["value"] indique que l'option prend un seul argument
 * obligatoire, alors que ["value", ["path", "host"]] indique qu'il faut une
 * valeur obligatoire, suivi éventuellement d'un chemin puis d'un hôte, à moins
 * d'utiliser '--' pour arrêter la liste des arguments facultatifs.
 *
 * Si on spécifie un nombre, alors c'est le nombre d'argument obligatoires de
 * type "value". Spécifier true revient à spécifier 1
 *
 * comme raccourci, ajouter un ':' à une option courte ou '=' à une option
 * longue revient à spécifier true pour la valeur args si elle n'a pas déjà été
 * définie, e.g
 * - ["-o:"] est équivalent à ["-o", "args" => true]
 * - ["-o:", "args" => 3] est équivalent à ["-o", "args" => 3]
 *
 * de même, ajouter un '::' à une option courte revient à spécifier [["value"]]
 * pour la valeur args si elle n'a pas déjà été définie, e.g
 * - ["-o::"] est équivalent à ["-o", "args" => [["value"]]]
 * - ["-o::", "args" => 3] est équivalent à ["-o", "args" => 3]
 *
 * :: action
 * c'est une fonction à appeler quand cette option est utilisée. Sa signature
 * est ($dest, $value, $name, $arg, $def)
 *
 * Si la fonction est une méthode, elle doit être statique.
 * Si le nom de l'action est de la forme "->method", alors la destination doit
 * être un objet, et la méthode correspondante est appelée avec les arguments
 * ($value, $name, $arg, $def)
 *
 * Certains noms d'actions sont réservés:
 * "--inc" incrémenter la valeur (action par défaut pour une option sans argument)
 * "--dec" décrémenter la valeur (implique "inverse" => true)
 * "--set" forcer à la valeur spécifiée dans la clé "value"
 * "--add" ajouter au tableau destination
 * "--adds" ajouter chaque argument au tableau destination
 * "--merge" fusionner dans le tableau destination
 * "--merges" fusionner chaque argument dans le tableau destination
 * "--meta" est utilisé en interne pour mettre à jour les champs args et command
 *
 * --adds (resp. --merges) sont utiles si l'option prend plusieurs arguments:
 * l'action --add (resp. --merge) est appliqué sur chaque argument de l'option
 *
 * :: name
 * le nom de propriété ou de clé à initialiser en réponse à l'utilisation de
 * l'option.
 *
 * Si la destination est un objet, alors c'est une propriété qui est mise à
 * jour, sinon ça doit être un tableau, et c'est une clé qui est mise à jour.
 *
 * l'utilisation de "property" ou "key" forcent respectivement la mise à jour
 * d'une propriété ou d'une clé
 *
 * "property" est prioritaire par rapport à "key", qui est lui même prioritaire
 * par rapport à "name". "name" est automatiquement mis à jour en fonction de la
 * valeur de "property" ou "key"
 *
 * :: ensure_array
 * force la destination à être un tableau. C'est le cas par défaut pour le reste
 * des arguments et/ou si le nombre d'argument possible est plus grand que 1
 *
 * XXX il manque les fonctionnalités suivantes:
 * - gestion des commandes
 * - génération des scripts d'auto-complétion bash
 */
class ArgsParser {
  const KIND_OPTION = "option";
  const KIND_COMMAND = "command";

  #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  # méthodes surchargeables

  protected function invalidDef(string $msg): ArgsException {
    return new ArgsException("invalid definition: $msg");
  }

  protected function notEnoughArgs(int $needed, ?string $what=null): ArgsException {
    if ($what !== null) $what = " for $what";
    return new ArgsException("needs $needed more argument(s)$what");
  }

  protected function tooManyArgs(int $count, int $expected, ?string $what=null): ArgsException {
    if ($what !== null) $what = " for $what";
    return new ArgsException("too many arguments$what (expected $expected, got $count)");
  }

  protected function invalidArg(string $arg, string $kind): ArgsException {
    return new ArgsException("$arg: invalid $kind");
  }

  protected function ambiguousArg(string $arg, string $kind, array $candidates): ArgsException {
    $candidates = implode(", ", $candidates);
    return new ArgsException("$arg: ambiguous $kind (possible candidates are $candidates)");
  }

  #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

  /**
   * Construire un parser avec les définitions spécifiées. La méthode reset()
   * est appelée automatiquement.
   *
   * @throws ArgsException en cas d'erreur de syntaxe
   */
  function __construct(array $defs) {
    [$meta, $sections, $sdefs, $ldefs, $cdefs, $rdef] = $this->parseDefs($defs);
    $defaultDefs = [$defs, $meta, $sections, $sdefs, $ldefs, $cdefs, $rdef];
    $this->defaults = $defaultDefs;
    $this->defs = $defs;
    $this->meta = $meta;
    $this->sections = $sections;
    $this->sdefs = $sdefs;
    $this->ldefs = $ldefs;
    $this->cdefs = $cdefs;
    $this->rdef = $rdef;
  }

  /** @var array définitions par défaut */
  private $defaults;

  /** @var array définitions courantes */
  private $defs;
  private $meta;
  private $sections;
  private $sdefs;
  private $ldefs;
  private $cdefs;
  private $rdef;

  /** @var object|array objet destination */
  protected $dest;

  #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

  /**
   * Analyser la ligne de commande et initialiser l'objet ou le tableau $dest
   * selon les règles formulées par les définitions
   *
   * @throws ArgsException
   */
  function parse(&$dest, array $args=null): void {
    if ($args === null) {
      global $argv;
      $args = array_slice($argv, 1);
    }
    if ($dest === null) $dest = new stdClass();
    $this->setDest($dest);
    $args = $this->normArgs($args);
    $this->parseArgs($args);
    $this->unsetDest();
  }

  function setDest(&$dest): void {
    $this->dest =& $dest;
  }

  function unsetDest(): void {
    unset($this->dest);
  }

  #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

  /**
   * Modifier l'ensemble courant des arguments valides à ceux définis par la
   * commande spécifiée. Utiliser null pour reprendre l'analyse avec les
   * arguments par défaut
   */
  function select(?string $command): void {
    if ($command === null) {
      [$this->defs, $this->meta, $this->sections, $this->sdefs, $this->ldefs, $this->cdefs, $this->rdef,
      ] = $this->defaults;
    } else {
      $dyncdefs = $this->cdefs;
      $this->checkDyncdefs($this->meta["dynamic_command"], $command, $dyncdefs);
      $def = A::get($dyncdefs, $command);
      if ($def === null) throw $this->invalidArg($command, self::KIND_COMMAND);
      $this->defs = $def["cmd_args"];
      [$this->meta, $this->sections, $this->sdefs, $this->ldefs, $this->cdefs, $this->rdef,
      ] = $def["cmd_defs"];
    }
  }

  #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

  /**
   * analyser les définitions et retourner les tableaux suivants:
   * - $meta, les méta-informations sur la liste des définitions
   * - $sections, une liste de sections avec leurs définitions normalisées. la
   * première section est la section par défaut
   * - sdefs, le tableau des options courtes
   * - ldefs, le tableau des options longues
   * - cdefs, le tableau des commandes
   * - rdef, la définition des arguments restants
   *
   * si $autohelp, rajouter automatiquement une option --help qui affiche
   * l'aide si elle n'existe pas déjà
   *
   * si $autoremains, rajouter automatiquement la gestion des arguments
   * restants en les plaçant dans la propriété de destination "args", si cette
   * gestion n'est pas déjà définie.
   *
   * @throws ArgsException en cas d'erreur de syntaxe
   */
  function parseDefs(array $defs, bool $autohelp=true, bool $autoremains=true): array {
    [$defs, $meta] = A::split_assoc($defs);

    md::ensure_schema($meta, ref_args::DEFS_SCHEMA, null, false);
    $autohelp = A::replace_n($meta, "autohelp", $autohelp);
    $autoremains = A::replace_n($meta, "autoremains", $autoremains);
    $msections = A::getdel($meta, "sections");

    # Merger les tableaux supplémentaires
    $set_defaults = $meta["set_defaults"];
    $merge_arrays = $meta["merge_arrays"];
    $merge = $meta["merge"];
    if ($merge_arrays !== null || $merge !== null) {
      if ($merge_arrays === null) $merge_arrays = [];
      if ($merge !== null) $merge_arrays[] = $merge;
    }
    A::ensure_array($defs);
    if ($set_defaults !== null || $merge_arrays !== null) {
      if ($set_defaults !== null) {
        # set_defaults
        [$defs_defaults, $meta_defaults] = A::split_assoc($set_defaults);
        md::ensure_schema($meta_defaults, ref_args::DEFS_SCHEMA, null, false);
        $msections_defaults = A::getdel($meta_defaults, "sections");
        A::update_n($defs, $defs_defaults);
        A::update_n($meta, $meta_defaults);
        A::update_n($msections, $msections_defaults);
      }
      if ($merge_arrays !== null) {
        $cur_defs = $defs; $cur_meta = $meta; $cur_msections = $msections;
        $defs = []; $meta = []; $msections = [];
        foreach ($merge_arrays as $merge) {
          [$merge_defs, $merge_meta] = A::split_assoc($merge);
          md::ensure_schema($merge_meta, ref_args::DEFS_SCHEMA, null, false);
          $merge_msections = A::getdel($merge_meta, "sections");
          A::merge_nn($defs, $merge_defs);
          A::merge_nn($meta, $merge_meta);
          A::merge_nn($msections, $merge_msections);
        }
        A::merge($defs, $cur_defs);
        A::merge($meta, $cur_meta);
        A::merge($msections, $cur_msections);
      }
    }

    global $argv;
    A::replace_n($meta, "name", basename($argv[0]));

    $dynamic_command = $meta["dynamic_command"];
    if ($dynamic_command !== null) {
      if (is_string($dynamic_command) && class_exists($dynamic_command)) {
        $dynamic_command = func::cons($dynamic_command);
      } elseif (func::is_static($dynamic_command)  || func::is_method($dynamic_command)) {
        $dynamic_command = new DynamicCommandMethod($dynamic_command);
      } else {
        throw $this->invalidDef("dynamic_command");
      }
      $meta["dynamic_command"] = $dynamic_command;
    }

    $argsname = $meta["argsname"];
    $argsproperty = $meta["argsproperty"];
    $argskey = $meta["argskey"];
    if ($argsname === null && $argsproperty === null && $argskey === null) $argsname = "args";
    elseif ($argsname === null && $argsproperty !==null) $argsname = $argsproperty;
    elseif ($argsname === null && $argskey !==null) $argsname = $argskey;
    $meta["argsname"] = $argsname;

    $commandname = $meta["commandname"];
    $commandproperty = $meta["commandproperty"];
    $commandkey = $meta["commandkey"];
    if ($commandname === null && $commandproperty === null && $commandkey === null) $commandname = "command";
    elseif ($commandname === null && $commandproperty !==null) $commandname = $commandproperty;
    elseif ($commandname === null && $commandkey !==null) $commandname = $commandkey;
    $meta["commandname"] = $commandname;

    # analyser les options
    $sections = [];
    $sdefs = [];
    $ldefs = [];
    $cdefs = [];
    $rdef = null;
    $have_remains = false;
    $have_help = false;

    if ($msections !== null) {
      foreach ($msections as $msection) {
        [$mdefs, $msection] = A::split_assoc($msection);
        $sections[] = $this->buildSection($msection, $mdefs, $meta, $sdefs, $ldefs, $cdefs, $rdef, $have_remains, $have_help);
      }
    }
    # calculer la section par défaut à la fin, pour qu'elle puisse surcharger
    # des options définies précédemment. par contre, il faut la mettre au début
    # du tableau $sections
    array_unshift($sections, $this->buildSection([], $defs, $meta, $sdefs, $ldefs, $cdefs, $rdef, $have_remains, $have_help));

    if (!$have_remains && $autoremains) {
      $def = [
        "args" => [null],
        "action" => "--meta",
        "name" => "args",
      ];
      $this->parseDef($def, $sdefs, $ldefs, $cdefs, $rdef);
    }
    if (!$have_help && $autohelp) {
      $self = $this;
      $def = [
        "--help",
        "action" => function() use ($self, $meta, $sections) {
          $self->printHelp($meta, $sections);
          exit(0);
        },
        "help" => "Afficher l'aide",
      ];
      $this->parseDef($def, $sdefs, $ldefs, $cdefs, $rdef);
      $def = [
        "--help-all",
        "action" => function() use ($self, $meta, $sections) {
          $self->printHelp($meta, $sections, true);
          exit(0);
        },
        "help" => "Afficher l'aide complète",
      ];
      $this->parseDef($def, $sdefs, $ldefs, $cdefs, $rdef);
    }
    return [$meta, $sections, $sdefs, $ldefs, $cdefs, $rdef];
  }

  private function buildSection(
    ?array $section, ?array $defs,
    ?array $meta, array  &$sdefs, array &$ldefs, array &$cdefs, ?array &$rdef,
    bool   &$have_remains, bool &$have_help
  ): array {
    A::ensure_array($section);
    $section["defs"] = A::with($defs);
    md::ensure_schema($section, ref_args::SECTION_SCHEMA, null, false);
    foreach ($section["defs"] as &$def) {
      if ($this->isGroup($def)) {
        # groupe d'option
        $count = count($def);
        for ($i = 1; $i < $count; $i++) {
          $this->parseDef($def[$i], $sdefs, $ldefs, $cdefs, $rdef, $have_remains, $have_help);
        }
      } else {
        # option simple
        $this->parseDef($def, $sdefs, $ldefs, $cdefs, $rdef, $have_remains, $have_help);
      }
    }; unset($def);
    return $section;
  }

  /**
   * vérifier si $def est de la forme ["group", ...$defs]
   *
   * au niveau de l'affichage de l'aide, toutes les options d'un même groupe
   * sont affichées ensemble, avec comme aide le texte du premier élément du
   * groupe
   */
  private function isGroup(array $def): bool {
    # $def ne doit pas être associatif
    $assoc = A::split_assoc($def)[1];
    if ($assoc !== null) return false;
    # il faut au moins 2 valeurs et la première valeur doit être "group"
    if (count($def) < 2 || $def[0] !== "group") return false;
    # toutes les valeurs suivantes doivent être des tableaux
    $count = count($def);
    for ($i = 1; $i < $count; $i++) {
      if (!is_array($def[$i])) return false;
    }
    return true;
  }

  private function parseDef(
    array &$def,
    array &$sdefs, array &$ldefs, array &$cdefs, ?array &$rdef,
    bool  &$have_remains=false, bool &$have_help=false
  ): void {
    # Merger les tableaux supplémentaires
    $set_defaults = A::getdel($def, "set_defaults");
    $merge_arrays = A::getdel($def, "merge_arrays");
    $merge = A::getdel($def, "merge");
    if ($merge_arrays !== null || $merge !== null) {
      if ($merge_arrays === null) $merge_arrays = [];
      if ($merge !== null) $merge_arrays[] = $merge;
    }
    if ($set_defaults !== null) {
      A::update_n($def, $set_defaults);
    }
    if ($merge_arrays !== null) {
      foreach ($merge_arrays as $merge) {
        A::merge_nn($def, $merge);
      }
    }

    [$seq, $assoc] = A::split_assoc($def);
    md::ensure_schema($assoc, ref_args::DEF_SCHEMA, null, false);

    $args_default = null;
    if ($seq === null) {
      $is_remains = $have_remains = true;
    } else {
      $is_remains = false;
      foreach ($seq as &$opt) {
        if (str::_starts_with("--", $opt) && str::_ends_with("=", $opt)) {
          $opt = str::without_suffix("=", $opt);
          if ($args_default === null) $args_default = true;
        } elseif (str::_starts_with("-", $opt) && str::_ends_with("::", $opt)) {
          $opt = str::without_suffix("::", $opt);
          if ($args_default === null) $args_default = [["value"]];
        } elseif (str::_starts_with("-", $opt) && str::_ends_with(":", $opt)) {
          $opt = str::without_suffix(":", $opt);
          if ($args_default === null) $args_default = true;
        }
      }; unset($opt);
      if (in_array("--help", $seq)) $have_help = true;
    }
    # $desc est une description de l'argument présentement défini, utilisée dans
    # les levées d'exceptions
    if ($is_remains) $desc = "remaining args";
    else $desc = $def[0];

    ## arguments que cette définition peut prendre
    $args = $assoc["args"];
    if ($args === null) $args = $assoc["arg"];
    if ($args === null) $args = $args_default;
    if ($args === true) $args = 1;
    if (is_int($args)) $args = array_fill(0, $args, "value");
    $args = A::with($args);
    $have_args = $args !== [];
    $arg_values = null;
    $min_args = 0;
    $max_args = 0;
    $argsdesc = [];
    if ($have_args) {
      $reqs = [];
      $opts = [];
      $have_opt = false;
      $have_null = false;

      $nb_seen = 0;
      $opt_args = null;
      foreach ($args as $arg) {
        $nb_seen++;
        if (is_string($arg)) {
          $reqs[] = $arg;
          $argsdesc[] = strtoupper($arg);
        } elseif (is_array($arg)) {
          $opt_args = $arg;
          break;
        } elseif ($arg === null) {
          $have_null = true;
          break;
        } else {
          throw $this->invalidDef("$desc: arg must be string, array or null");
        }
      }
      if ($nb_seen != count($args)) {
        throw $this->invalidDef("$desc: invalid args format");
      }

      $opt_argsdesc = [];
      $lastarg = strtoupper(ref_args::ARGS_ALLOWED_VALUES[0]);
      if ($opt_args !== null) {
        $nb_seen = 0;
        foreach ($opt_args as $arg) {
          $nb_seen++;
          if (is_string($arg)) {
            $have_opt = true;
            $opts[] = $arg;
            $lastarg = strtoupper($arg);
            $opt_argsdesc[] = "$lastarg";
          } elseif ($arg === null) {
            $have_null = true;
            break;
          } else {
            throw $this->invalidDef("$desc: optional arg must be string or null");
          }
        }
        if ($nb_seen != count($opt_args)) {
          throw $this->invalidDef("$desc: invalid args format");
        }
        if (!$have_opt) $have_null = true;
      }
      if ($have_null) $opt_argsdesc[] = "${lastarg}s...";
      if ($opt_argsdesc) {
        $argsdesc[] = "[".implode(" ", $opt_argsdesc)."]";
      }

      $min_args = count($reqs);
      $nb_opts = count($opts);
      if ($have_null) $max_args = PHP_INT_MAX;
      else $max_args = $min_args + $nb_opts;

      $arg_values = array_merge($reqs, $opts);
      foreach ($arg_values as $arg_value) {
        if (!in_array($arg_value, ref_args::ARGS_ALLOWED_VALUES)) {
          throw $this->invalidDef("$desc: arg value must be in ".implode(", ", ref_args::ARGS_ALLOWED_VALUES));
        }
      }
    }
    $argsdesc = implode(" ", $argsdesc);
    $argsdesc = A::replace_n($assoc, "argsdesc", $argsdesc);

    # ensure_array vaut true par défaut pour remains ou si le nombre maximum
    # d'arguments est plus grand que 1
    $ensure_array = $is_remains || $max_args > 1? true: false;
    $ensure_array = A::replace_n($assoc, "ensure_array", $ensure_array);
    # normaliser l'action
    $action = $assoc["action"];
    $defaultAction = $action === null;
    if ($defaultAction) {
      if ($have_args) {
        $action = "--set";
      } else {
        if ($assoc["value"] !== null) $action = "--set";
        elseif ($assoc["inverse"]) $action = "--dec";
        else $action = "--inc";
      }
    }
    $assoc["action"] = $action;
    $is_func = !is_string($action) || substr($action, 0, 2) != "--";
    # normaliser le nom
    $name = $assoc["name"];
    $property = $assoc["property"];
    $key = $assoc["key"];
    if (!$is_func && !$is_remains && $name === null && $property === null && $key === null) {
      # si on ne précise pas le nom de la propriété, la dériver à partir du
      # nom de l'option la plus longue
      $longest = null;
      $maxlen = 0;
      foreach ($seq as $item) {
        if (substr($item, 0, 2) == "--") {
          $long = substr($item, 2);
          $len = strlen($long);
          if ($len > $maxlen) {
            $longest = $long;
            $maxlen = $len;
          }
        }
      }
      if ($longest === null) {
        # si pas d'option longue, essayer avec nom de commande
        $maxlen = 0;
        foreach ($seq as $item) {
          if (substr($item, 0, 1) != "-") {
            $long = $item;
            $len = strlen($long);
            if ($len > $maxlen) {
              $longest = $long;
              $maxlen = $len;
            }
          }
        }
      }
      if ($longest === null) {
        # sinon avec option courte
        $maxlen = 0;
        foreach ($seq as $item) {
          if (substr($item, 0, 1) == "-") {
            $long = substr($item, 1);
            $len = strlen($long);
            if ($len > $maxlen) {
              $longest = $long;
              $maxlen = $len;
            }
          }
        }
      }
      if ($longest !== null) {
        $longest = preg_replace('/[^A-Za-z0-9]+/', "_", $longest);
        if (preg_match('/^[0-9]/', $longest)) {
          # le nom de la propriété ne doit pas commencer par un chiffre
          $longest = "p$longest";
        }
        $name = $longest;
      }
    } elseif ($name === null && $property !== null) {
      $name = $property;
    } elseif ($name === null && $key !== null) {
      $name = $key;
    }
    $assoc["name"] = $name;
    $assoc["property"] = $property;
    $assoc["key"] = $key;

    $cmd_args = $assoc["cmd_args"];
    if ($cmd_args !== null) {
      A::replace_n($cmd_args, "purpose", $assoc["help"]);
      $assoc["cmd_defs"] = $this->parseDefs($cmd_args);
    }

    $basedef = array_merge($assoc, [
      "is_option" => false, # est-ce une option?
      "options" => null, # options valides
      "option" => null, # valeur de cette option
      "is_short" => false, # est-ce une option courte?
      "is_long" => false, # est-ce une option longue?
      "is_command" => false, # est-ce une commande?
      "commands" => null, # commandes valides
      "command" => null, # valeur de cette commande
      "is_remains" => false, # est-ce le reste des arguments de la ligne de commande?
      "have_args" => $have_args, # cette définition (option/commande/reste) prend-elle des arguments?
      "arg_types" => $arg_values, # type des arguments parmi value, path, host
      "min_args" => $min_args, # nombre minimum d'arguments (nombre d'arguments requis)
      "max_args" => $max_args, # nombre maximum d'arguments (différent si certains arguments sont optionnels)
    ]);
    if ($is_remains) {
      $def = $rdef = array_merge($basedef, [
        "is_remains" => true,
      ]);
    } else {
      $kind = $assoc["kind"];
      switch ($kind) {
      case "o":
      case "opt":
      case "option":
        $kind = self::KIND_OPTION;
        break;
      case "c":
      case "cmd":
      case "command":
        $kind = self::KIND_COMMAND;
        break;
      default:
        $kind = null; # autodétecter sur la base du premier
      }
      $bdef = array_merge($seq, $basedef);
      $first = true;
      $first_item = null;
      foreach ($seq as $item) {
        if ($item == "--") {
          $this->invalidDef("$item: is reserved");
        }
        if ($kind === null) {
          if (substr($item, 0, 1) == "-") $kind = self::KIND_OPTION;
          else $kind = self::KIND_COMMAND;
        }
        if ($first) $first_item = $item;
        if ($kind === self::KIND_OPTION) {
          if (substr($item, 0, 1) == "-" && strlen($item) == 2) {
            $sopt = substr($item, 1);
            $sdefs[$sopt] = $sdef = array_merge($bdef, [
              "is_option" => true,
              "options" => $seq,
              "option" => $item,
              "is_short" => true,
            ]);
            if ($first) $def = $sdef;
          } else {
            $lopt = $item;
            $ldefs[$lopt] = $ldef = array_merge($bdef, [
              "is_option" => true,
              "options" => $seq,
              "option" => $item,
              "is_long" => true,
            ]);
            if ($first) $def = $ldef;
          }
        } elseif ($kind == "command") {
          $cmd = $item;
          $cdefs[$cmd] = $cdef = array_merge($bdef
            , $defaultAction? [
              "action" => "--meta",
              "name" => "command",
              "value" => $first_item,
            ]: []
            , [
              "is_command" => true,
              "commands" => $seq,
              "command" => $item,
            ]);
          if ($first) $def = $cdef;
        }
        $first = false;
      }
    }
  }

  #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

  private function checkDyncdefs(?IDynamicCommand $dc, string $command, ?array &$dyncdefs, bool $virtual=false): ?string {
    if ($dc === null) return null;
    if ($dc instanceof DynamicCommandMethod) $dc->setDest($this->dest);
    $dcdefs = $dc->getCommandDefs($command, $virtual);
    if ($dcdefs === null) return null;

    [$dcmeta, $dcsections, $dcsdefs, $dcldefs, $dccdefs, $dcrdef] = $this->parseDefs($dcdefs);
    foreach ($dccdefs as $dcname => $dccdef) {
      $dyncdefs[$dcname] = $dccdef;
    }
    return A::has($dyncdefs, $command)? "cmd": null;
  }

  /**
   * normaliser les arguments de $args: détacher les options les unes des
   * autres et réordonner les arguments
   *
   * @throws ArgsException
   */
  function normArgs(array $args, bool $reset=true): array {
    if ($reset) $this->select(null);
    $meta = $this->meta;
    $sdefs = $this->sdefs;
    $ldefs = $this->ldefs;
    $cdefs = $this->cdefs;

    $i = 0;
    $max = count($args);
    $norm_args = [];
    $first_group = true;
    while (true) {
      $parse_opts = true;
      $options = [];
      $remains = [];
      while ($i < $max) {
        $arg = $args[$i++];
        if ($arg == "//") {
          # changement de groupe d'option, remettre à zéro l'analyseur
          $this->select(null);
          $meta = $this->meta;
          $sdefs = $this->sdefs;
          $ldefs = $this->ldefs;
          $cdefs = $this->cdefs;
          break;

        } elseif (!$parse_opts) {
          # le reste de la ligne ne contient que des arguments
          $remains[] = $arg;
          continue;

        } elseif ($arg == "--") {
          # fin des options
          $parse_opts = false;
          continue;
        }

        $pos = strpos($arg, "=");
        $name = $pos !== false? substr($arg, 0, $pos): $arg;
        $dyncdefs = $cdefs;
        $kind = $this->checkDyncdefs($meta["dynamic_command"], $arg, $dyncdefs);
        if ($kind === null) {
          if (A::has($ldefs, $name)) $kind = "lopt";
          elseif (A::has($dyncdefs, $name)) $kind = "cmd";
          elseif (substr($arg, 0, 2) == "--") $kind = "lopt";
          elseif (substr($arg, 0, 1) == "-") $kind = "sopt";
          elseif (A::has($dyncdefs, $arg)) $kind = "cmd";
        }

        if ($kind == "lopt") {
          #######################################################################
          # option longue
          $pos = strpos($arg, "=");
          if ($pos !== false) {
            # option avec valeur
            $name = substr($arg, 0, $pos);
            $value = substr($arg, $pos + 1);
          } else {
            # option sans valeur
            $name = $arg;
            $value = null;
          }
          $def = A::get($ldefs, $name);
          if ($def === null) {
            # chercher une correspondance
            $length = strlen($name);
            $candidates = [];
            foreach (array_keys($ldefs) as $lopt) {
              if (substr($lopt, 0, $length) == $name) {
                $candidates[] = $lopt;
              }
            }
            switch (count($candidates)) {
            case 0:
              throw $this->invalidArg($arg, self::KIND_OPTION);
            case 1:
              $name = $candidates[0];
              break;
            default:
              throw $this->ambiguousArg($name, self::KIND_OPTION, $candidates);
            }
            $def = A::get($ldefs, $name);
          }

          $option = $def["option"];
          if ($def["have_args"]) {
            $min_args = $def["min_args"];
            $max_args = $def["max_args"];
            $values = [];
            if ($value !== null) {
              $values[] = $value;
              $offset = 1;
            } elseif ($min_args == 0) {
              # cas particulier: la première valeur doit être collée à l'option si $max_args==1
              $offset = $max_args == 1? 1: 0;
            } else {
              $offset = 0;
            }
            $this->checkEnoughArgs("option $option",
              self::consume_args($args, $i, $values, $offset, $min_args, $max_args, true));
            if ($min_args == 0 && $max_args == 1) {
              # cas particulier: la première valeur doit être collée à l'option
              if (count($values) > 0) {
                $options[] = "$option=$values[0]";
                $values = array_slice($values, 1);
              } else {
                $options[] = $option;
              }
            } else {
              $options[] = $option;
            }
            $options = array_merge($options, $values);
          } else {
            $options[] = $option;
          }

        } elseif ($kind == "sopt") {
          #######################################################################
          # option courte
          $pos = 1;
          $length = strlen($arg);
          while ($pos < $length) {
            $def = A::get($sdefs, substr($arg, $pos, 1));
            if ($def === null) throw $this->invalidArg($arg, self::KIND_OPTION);

            $option = $def["option"];
            if ($def["have_args"]) {
              $min_args = $def["min_args"];
              $max_args = $def["max_args"];
              $values = [];
              if ($length > $pos + 1) {
                # option avec valeur
                $values[] = substr($arg, $pos + 1);
                $offset = 1;
                $pos = $length;
              } elseif ($min_args == 0) {
                # cas particulier: la première valeur doit être collée à l'option si $max_args==1
                $offset = $max_args == 1? 1: 0;
              } else {
                # option sans valeur
                $offset = 0;
              }
              $this->checkEnoughArgs("option $option",
                self::consume_args($args, $i, $values, $offset, $min_args, $max_args, true));
              if ($min_args == 0 && $max_args == 1) {
                # cas particulier: la première valeur doit être collée à l'option
                if (count($values) > 0) {
                  $options[] = "$option$values[0]";
                  $values = array_slice($values, 1);
                } else {
                  $options[] = $option;
                }
              } else {
                $options[] = $option;
              }
              $options = array_merge($options, $values);
            } else {
              $options[] = $option;
            }
            $pos++;
          }

        } elseif ($kind == "cmd") {
          #######################################################################
          # commande
          $pos = strpos($arg, "=");
          if ($pos !== false) {
            # option avec valeur
            $name = substr($arg, 0, $pos);
            $value = substr($arg, $pos + 1);
          } else {
            # option sans valeur
            $name = $arg;
            $value = null;
          }
          $def = A::get($dyncdefs, $name);
          if ($def === null) throw $this->invalidArg($arg, self::KIND_COMMAND);

          $command = $def["command"];
          if ($def["have_args"]) {
            $min_args = $def["min_args"];
            $max_args = $def["max_args"];
            $values = [];
            if ($value !== null) {
              $values[] = $value;
              $offset = 1;
            } elseif ($min_args == 0) {
              # cas particulier: la première valeur doit être collée à la commande si $max_args==1
              $offset = $max_args == 1? 1: 0;
            } else {
              $offset = 0;
            }
            $this->checkEnoughArgs("command $command",
              self::consume_args($args, $i, $values, $offset, $min_args, $max_args, true));
            if ($min_args == 0 && $max_args == 1) {
              # cas particulier: la première valeur doit être collée à l'option
              if (count($values) > 0) {
                $options[] = "$command=$values[0]";
                $values = array_slice($values, 1);
              } else {
                $options[] = $command;
              }
            } else {
              $options[] = $command;
            }
            $options = array_merge($options, $values);
          } else {
            $options[] = $command;
          }
          $this->select($command);
          $meta = $this->meta;
          $sdefs = $this->sdefs;
          $ldefs = $this->ldefs;
          $cdefs = $this->cdefs;

        } else {
          # argument
          $remains[] = $arg;
        }
      }
      if ($first_group) $first_group = false;
      else $norm_args[] = "//";
      $norm_args = array_merge($norm_args, $options, ["--"], $remains);
      if ($i >= $max) break;
    }
    return $norm_args;
  }

  /**
   * consommer les arguments de $src en avançant l'index $srci et provisionner
   * $dest à partir de $desti. si $desti est plus grand que 0, celà veut dire
   * que $dest a déjà commencé à être provisionné, et qu'il faut continuer.
   *
   * $destmin est le nombre minimum d'arguments à consommer. $destmax est le
   * nombre maximum d'arguments à consommer.
   *
   * $srci est la position de l'élément courant à consommer le cas échéant
   * retourner le nombre d'arguments qui manquent (ou 0 si tous les arguments
   * ont été consommés)
   *
   * pour les arguments optionnels, ils sont consommés tant qu'il y en a de
   * disponible, ou jusqu'à la présence de '--'. Si $keepsep, l'argument '--'
   * est gardé dans la liste des arguments optionnels.
   */
  private static function consume_args($src, &$srci, &$dest, $desti, $destmin, $destmax, bool $keepsep): int {
    $srcmax = count($src);
    # arguments obligatoires
    while ($desti < $destmin) {
      if ($srci < $srcmax) {
        $dest[] = $src[$srci];
      } else {
        # pas assez d'arguments
        return $destmin - $desti;
      }
      $srci++;
      $desti++;
    }
    # arguments facultatifs
    while ($desti < $destmax && $srci < $srcmax) {
      $opt = $src[$srci];
      if ($opt === "--") {
        # fin des options facultatives
        if ($keepsep) $dest[] = $opt;
        $srci++;
        break;
      }
      $dest[] = $opt;
      $srci++;
      $desti++;
    }
    return 0;
  }

  private function checkEnoughArgs(?string $what, int $count) {
    if ($count > 0) {
      throw $this->notEnoughArgs($count, $what);
    }
  }

  private static function ensure_type(&$value, ?string $type): void {
    switch ($type) {
    case "?array":
    case "array":
    case "?array[]":
    case "array[]":
      if ($value !== null) {
        $value = explode(",", $value);
      }
      break;
    }
    types::verifix($type, $value, $result, true);
  }

  private static function ensure_types(&$values, $types): void {
    if (!$types) return;
    if (is_array($values)) {
      $types = A::with($types);
      $index = 0;
      foreach ($values as &$value) {
        if (array_key_exists($index, $types)) $type = $types[$index];
        $index++;
        self::ensure_type($value, $type);
      }; unset($value);
    } else {
      if (is_array($types)) $types = A::first($types);
      self::ensure_type($values, $types);
    }
  }

  #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

  /** si $arg == \//, le remplacer par // */
  private static function fix_arg(string $arg): string {
    if (preg_match('/^\\\\+\/\//', $arg)) {
      $arg = substr($arg, 1);
    }
    return $arg;
  }

  /**
   * analyser les arguments et mettre à jour l'objet $this->dest
   * $args *doit* avoir été normalisé avec {@link normArgs()} d'abord
   *
   * @throws ArgsException
   */
  function parseArgs(array $args, bool $reset=true) {
    if ($reset) $this->select(null);
    $meta = $this->meta;
    $sdefs = $this->sdefs;
    $ldefs = $this->ldefs;
    $cdefs = $this->cdefs;
    $rdef = $this->rdef;

    $i = 0;
    $max = count($args);
    while (true) {
      # d'abord traiter les options
      $arg = null;
      while ($i < $max) {
        $arg = $args[$i++];
        if ($arg == "//") {
          # fin d'un groupe d'options
          break;
        } elseif ($arg == "--") {
          # fin des options
          break;
        }

        $pos = strpos($arg, "=");
        $name = $pos !== false? substr($arg, 0, $pos): $arg;
        $dyncdefs = $cdefs;
        $kind = $this->checkDyncdefs($meta["dynamic_command"], $arg, $dyncdefs);
        if ($kind === null) {
          if (A::has($ldefs, $name)) $kind = "lopt";
          elseif (A::has($dyncdefs, $name)) $kind = "cmd";
          elseif (substr($arg, 0, 2) == "--") $kind = "lopt";
          elseif (substr($arg, 0, 1) == "-") $kind = "sopt";
          elseif (A::has($dyncdefs, $arg)) $kind = "cmd";
        }

        # obtenir l'option
        if ($kind == "lopt") {
          #######################################################################
          # option longue
          $pos = strpos($arg, "=");
          if ($pos !== false) {
            $option = substr($arg, 0, $pos);
            $ovalue = substr($arg, $pos + 1);
          } else {
            $option = $arg;
            $ovalue = null;
          }
          $def = A::get($ldefs, $option);
          $value = $def["value"];
          if ($def["have_args"]) {
            $min_args = $def["min_args"];
            $max_args = $def["max_args"];
            if ($pos !== false) {
              # option avec valeur facultative
              $value = [$ovalue];
              $offset = 1;
            } elseif ($min_args == 0 && $max_args == 1) {
              $value = A::with($value);
              $offset = 1;
            } else {
              $value = [];
              $offset = 0;
            }
            self::consume_args($args, $i, $value, $offset, $min_args, $max_args, false);
            self::ensure_types($value, $def["type"]);
          }

        } elseif ($kind == "sopt") {
          #######################################################################
          # option courte
          $def = A::get($sdefs, substr($arg, 1, 1));
          $value = $def["value"];
          if ($def["have_args"]) {
            $min_args = $def["min_args"];
            $max_args = $def["max_args"];
            $length = strlen($arg);
            if ($length > 2) {
              # option avec valeur facultative
              $value = [substr($arg, 2)];
              $offset = 1;
            } elseif ($min_args == 0 && $max_args == 1) {
              $value = A::with($value);
              $offset = 1;
            } else {
              $value = [];
              $offset = 0;
            }
            self::consume_args($args, $i, $value, $offset, $min_args, $max_args, false);
            self::ensure_types($value, $def["type"]);
          }

        } elseif ($kind == "cmd") {
          #######################################################################
          # commande
          $pos = strpos($arg, "=");
          if ($pos !== false) {
            $option = substr($arg, 0, $pos);
            $ovalue = substr($arg, $pos + 1);
          } else {
            $option = $arg;
            $ovalue = null;
          }
          $def = A::get($dyncdefs, $option);
          $value = $def["value"];
          if ($def["have_args"]) {
            $min_args = $def["min_args"];
            $max_args = $def["max_args"];
            if ($pos !== false) {
              # option avec valeur facultative
              $value = [$ovalue];
              $offset = 1;
            } elseif ($min_args == 0 && $max_args == 1) {
              $value = A::with($value);
              $offset = 1;
            } else {
              $value = [];
              $offset = 0;
            }
            self::consume_args($args, $i, $value, $offset, $min_args, $max_args, false);
            self::ensure_types($value, $def["type"]);
          }
          $this->select($def["command"]);
          $meta = $this->meta;
          $sdefs = $this->sdefs;
          $ldefs = $this->ldefs;
          $cdefs = $this->cdefs;
          $rdef = $this->rdef;
        }

        # puis traiter l'option
        $this->doAction($value, $arg, $def);
      }

      if ($arg === "//") {
        # fin d'un groupe d'options, réinitialiser l'analyseur
        $this->select(null);
        $meta = $this->meta;
        $sdefs = $this->sdefs;
        $ldefs = $this->ldefs;
        $cdefs = $this->cdefs;
        $rdef = $this->rdef;
        $this->updateMeta("command", null);

      } else {
        # construire la liste des arguments qui restent
        $rargs = [];
        while ($i < $max && $args[$i] !== "//") {
          $rargs[] = self::fix_arg($args[$i++]);
        }
        $ri = 0;
        $rmax = count($rargs);
        # puis traiter les arguments qui restent
        if ($rdef !== null && $rdef["have_args"]) {
          if ($rdef["max_args"] == PHP_INT_MAX) {
            # cas particulier: si le nombre d'arguments restants est non borné,
            # les prendre tous sans distinction ni traitement de '--'
            $values = $rargs;
            # mais tester tout de même s'il y a le minimum requis d'arguments
            $this->checkEnoughArgs(null, $rdef["min_args"] - $rmax);
          } else {
            $values = [];
            $this->checkEnoughArgs(null,
              self::consume_args($rargs, $ri, $values, 0, $rdef["min_args"], $rdef["max_args"], false));
            if ($ri <= $rmax - 1) throw $this->tooManyArgs($rmax, $ri);
          }
          self::ensure_types($values, $rdef["type"]);
          $this->doAction($values, null, $rdef);
        } else {
          if ($ri <= $rmax - 1) throw $this->tooManyArgs($rmax, $ri);
        }
      }

      if ($i >= $max) break;
    }
  }

  private function updateObject(string $action, string $property, $value): void {
    switch ($action) {
    case "--inc":
      oprop::inc($this->dest, $property);
      break;
    case "--dec":
      oprop::dec($this->dest, $property);
      break;
    case "--set":
      oprop::set($this->dest, $property, $value);
      break;
    case "--add":
      oprop::append($this->dest, $property, $value);
      break;
    case "--adds":
      foreach (A::with($value) as $value) {
        oprop::append($this->dest, $property, $value);
      }
      break;
    case "--merge":
      oprop::merge($this->dest, $property, $value);
      break;
    case "--merges":
      foreach (A::with($value) as $value) {
        oprop::merge($this->dest, $property, $value);
      }
      break;
    default:
      throw $this->invalidDef("$action: invalid action");
    }
  }

  private function updateArray(string $action, string $key, $value): void {
    switch ($action) {
    case "--inc":
      akey::inc($this->dest, $key);
      break;
    case "--dec":
      akey::dec($this->dest, $key);
      break;
    case "--set":
      akey::set($this->dest, $key, $value);
      break;
    case "--add":
      akey::append($this->dest, $key, $value);
      break;
    case "--adds":
      foreach (A::with($value) as $value) {
        akey::append($this->dest, $key, $value);
      }
      break;
    case "--merge":
      akey::merge($this->dest, $key, $value);
      break;
    case "--merges":
      foreach (A::with($value) as $value) {
        akey::merge($this->dest, $key, $value);
      }
      break;
    default:
      throw $this->invalidDef("$action: invalid action");
    }
  }

  private function updateDest(string $action, string $name, $value): void {
    switch ($action) {
    case "--inc":
      valx::inc($this->dest, $name);
      break;
    case "--dec":
      valx::dec($this->dest, $name);
      break;
    case "--set":
      valx::set($this->dest, $name, $value);
      break;
    case "--add":
      valx::append($this->dest, $name, $value);
      break;
    case "--adds":
      foreach (A::with($value) as $value) {
        valx::append($this->dest, $name, $value);
      }
      break;
    case "--merge":
      valx::merge($this->dest, $name, $value);
      break;
    case "--merges":
      foreach (A::with($value) as $value) {
        valx::merge($this->dest, $name, $value);
      }
      break;
    default:
      throw $this->invalidDef("$action: invalid action");
    }
  }

  private function updateMeta(string $name, $value): void {
    $meta = $this->meta;
    switch ($name) {
    case "args":
      $name = $meta["argsname"];
      $property = $meta["argsproperty"];
      $key = $meta["argskey"];
      $action = "--merge";
      break;
    case "command":
      $name = $meta["commandname"];
      $property = $meta["commandproperty"];
      $key = $meta["commandkey"];
      $action = $value !== null? "--add": "--set";
      break;
    default:
      throw IllegalAccessException::unexpected_state();
    }
    if ($property !== null) {
      $this->updateObject($action, $property, $value);
    } elseif ($key !== null) {
      $this->updateArray($action, $key, $value);
    } elseif ($name !== null) {
      $this->updateDest($action, $name, $value);
    }
  }

  private function doAction($value, ?string $arg, array $def) {
    $action = $def["action"];
    $name = $def["name"];
    $property = $def["property"];
    $key = $def["key"];
    $ensure_array = $def["ensure_array"];
    if ($ensure_array) {
      $value = A::with($value);
    } elseif (is_array($value)) {
      $count = count($value);
      if ($count == 0) $value = null;
      elseif ($count == 1) $value = $value[0];
    }

    if ($action === "--meta") {
      $this->updateMeta($name, $value);
    } else {
      if ($action === null) {
        # NOP
      } elseif (func::is_method($action)) {
        # méthode
        $func = $action;
        $func_args = [$value, $name, $arg, $this->dest, $def];
        func::fix_method($func, $this->dest);
        func::fix_args($func, $func_args);
        func::call($func, ...$func_args);
        return;
      } elseif (!is_string($action) || substr($action, 0, 2) != "--") {
        # fonction statique
        $func = $action;
        $func_args = [$value, $name, $arg, $this->dest, $def];
        func::fix_static($func, $this->dest);
        func::fix_args($func, $func_args);
        func::call($func, ...$func_args);
        return;
      }

      if ($property !== null) {
        $this->updateObject($action, $property, $value);
      } elseif ($key !== null) {
        $this->updateArray($action, $key, $value);
      } elseif ($name !== null) {
        $this->updateDest($action, $name, $value);
      }
    }
  }

  #~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

  /**
   * afficher l'aide correspondant à la liste normalisée de définitions $defs
   * et les méta-données $meta
   *
   * @param bool $showAll faut-il afficher les sections cachées?
   */
  function printHelp(array $meta, array $sections, bool $showAll=false) {
    if ($meta["prefix"]) echo $meta["prefix"];

    if ($meta["purpose"]) {
      echo "$meta[name]: $meta[purpose]\n";
    } elseif (!$meta["prefix"]) {
      echo "$meta[name]\n";
    }

    if ($meta["usage"]) {
      echo "\nUSAGE\n";
      foreach (A::with($meta["usage"]) as $usage) {
        echo "    $meta[name] $usage\n";
      }
    }

    if ($meta["description"]) {
      echo "\n$meta[description]\n";
    }

    $firstSection = null; # la première section est la section par défaut
    # mais il faut l'afficher en dernier...
    foreach ($sections as $section) {
      if (!$section["show"] && !$showAll) continue;

      if ($firstSection === null) {
        $firstSection = $section;
      } else {
        # dans les autres sections, afficher tel quel
        echo "\n";
        if ($section["title"]) echo "$section[title]\n";
        if ($section["prefix"]) echo "$section[prefix]\n";
        foreach ($section["defs"] as $def) {
          $this->printDef($def);
        }
        if ($section["suffix"]) echo "$section[suffix]\n";
      }
    }
    if ($firstSection !== null) {
      $section = $firstSection;
      # dans la section par défaut, séparer commandes et options
      $options = [];
      $commands = [];
      foreach ($section["defs"] as $def) {
        if ($this->isGroup($def)) {
          if ($def[1]["is_option"]) $options[] = $def;
          elseif ($def[1]["is_command"]) $commands[] = $def;
        } elseif ($def["is_option"]) {
          $options[] = $def;
        } elseif ($def["is_command"]) {
          $commands[] = $def;
        }
      }
      if ($options) {
        echo "\nOPTIONS\n";
        foreach ($options as $def) {
          $this->printDef($def);
        }
      }
      /** @var IDynamicCommand $dc */
      $dc = $meta["dynamic_command"];
      $dcCommands = $dc !== null? $dc->getCommands(): null;
      if ($commands || $dcCommands) echo "\nCOMMANDES\n";
      if ($commands) {
        foreach ($commands as $def) {
          $this->printDef($def);
        }
      }
      if ($dcCommands) {
        foreach ($dcCommands as $command) {
          $dyncdefs = [];
          $kind = $this->checkDyncdefs($dc, $command, $dyncdefs, true);
          if ($kind == "cmd") {
            $this->printDef($dyncdefs[$command]);
          }
        }
      }
    }

    if ($meta["suffix"]) echo $meta["suffix"];
  }

  private function printDef(array $def): void {
    if ($this->isGroup($def)) {
      $count = count($def);
      $help = null;
      $first = true;
      for ($i = 1; $i < $count; $i++) {
        $defi = $def[$i];
        $seq = A::split_assoc($defi)[0];
        echo "    ".implode(", ", $seq);
        if ($defi["have_args"]) echo " ".$defi["argsdesc"];
        echo "\n";

        if ($first) $help = $defi["help"];
        $first = false;
      }
    } else {
      $seq = A::split_assoc($def)[0];
      echo "    ".implode(", ", $seq);
      if ($def["have_args"]) echo " ".$def["argsdesc"];
      echo "\n";
      $help = $def["help"];
    }
    if ($help) {
      $help = str_replace("\n", "\n        ", $help);
      echo "        $help\n";
    }
  }
}