nur-sery/src/cli/ArgsParser.php

1547 lines
52 KiB
PHP
Raw Normal View History

2024-01-01 00:45:31 +04:00
<?php
namespace nur\sery\cli;
use nulib\str;
use nur\sery\php\func;
use nur\sery\ref\cli\ref_args;
use nur\sery\values\akey;
use nur\sery\values\oprop;
use nur\sery\values\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;
$sections[] = $this->buildSection([], $defs, $meta, $sdefs, $ldefs, $cdefs, $rdef, $have_remains, $have_help);
if ($msections !== null) {
foreach ($msections as $section) {
[$defs, $section] = A::split_assoc($section);
$sections[] = $this->buildSection($section, $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";
}
}
}