diff --git a/src/output/Console.php b/src/output/Console.php
new file mode 100644
index 0000000..e555126
--- /dev/null
+++ b/src/output/Console.php
@@ -0,0 +1,240 @@
+ self::LEVEL_DEBUG,
+ "d" => self::LEVEL_DEBUG,
+ "normal" => self::LEVEL_NORMAL,
+ "n" => self::LEVEL_NORMAL,
+ "major" => self::LEVEL_MAJOR,
+ "m" => self::LEVEL_MAJOR,
+ ];
+
+ const TYPE_PREFIXES = [
+ self::LEVEL_MAJOR => [
+ "section" => [true, "SECTION!", "===", "= ", " =", "==="],
+ "title" => ["TITLE!", null, "T ", "", "---"],
+ "desc" => ["DESC!", "> ", ""],
+ "error" => ["CRITICAL!", "E! ", ""],
+ "warn" => ["ATTENTION!", "W! ", ""],
+ "note" => ["IMPORTANT!", "N! ", ""],
+ "info" => ["IMPORTANT!", "I! ", ""],
+ ],
+ self::LEVEL_NORMAL => [
+ "section" => [true, "SECTION:", null, ">> ", " <<", "---"],
+ "title" => ["TITLE:", null, "T ", "", null],
+ "desc" => ["DESC:", "> ", ""],
+ "error" => ["ERROR:", "E ", ""],
+ "warn" => ["WARN:", "W ", ""],
+ "note" => ["NOTE:", "N ", ""],
+ "info" => ["INFO:", "I ", ""],
+ ],
+ self::LEVEL_DEBUG => [
+ "section" => [false, "s", null, ">> ", " <<", null],
+ "title" => ["t", "t ", ""],
+ "desc" => [">", "> ", ""],
+ "error" => ["e", "e ", ""],
+ "warn" => ["w", "w ", ""],
+ "note" => ["i", "i ", ""],
+ "info" => ["D", "D ", ""],
+ ],
+ ];
+
+ const RESULT_PREFIXES = [
+ "step" => ["*", "."],
+ "failure" => ["(FAILURE)", "✘"],
+ "success" => ["(SUCCESS)", "✔"],
+ "neutral" => [null, null],
+ ];
+
+ function __construct(?array $params=null) {
+ $debug = boolval(cl::get($params, "debug"));
+ $minLevel = cl::get($params, "min_level");
+ if ($minLevel === null) $minLevel = $debug? self::LEVEL_DEBUG: self::LEVEL_NORMAL;
+ if (!in_array($minLevel, self::VALID_LEVELS)) {
+ $minLevel = cl::get(self::LEVEL_MAP, $minLevel, $minLevel);
+ }
+ if (!in_array($minLevel, self::VALID_LEVELS)) {
+ throw new Exception("$minLevel: invalid level");
+ }
+
+ $this->out = new StdOutput(STDOUT);
+ $this->err = new StdOutput(STDERR);
+ $this->minLevel = intval($minLevel);
+ $this->pending = [];
+ $this->inSection = false;
+ $this->titles = null;
+ $this->actions = null;
+ }
+
+ /** @var StdOutput la sortie standard */
+ protected $out;
+
+ /** @var StdOutput la sortie d'erreur */
+ protected $err;
+
+ /** @var int level minimum que doivent avoir les messages pour être affichés */
+ protected $minLevel;
+
+ /** @var bool est-on dans une section? */
+ protected $inSection;
+
+ /** @var array|string section qui est en attente d'affichage */
+ protected $section;
+
+ function section($content, int $level=self::LEVEL_NORMAL): void {
+ $this->endSection();
+ $this->inSection = true;
+ if ($level < $this->minLevel) return;
+ $this->section = $content;
+ }
+
+ protected function printSection() {
+ if ($this->section !== null) {
+ $this->section = null;
+ }
+ }
+
+ protected function endSection(): void {
+ $this->inSection = false;
+ $this->section = null;
+ }
+
+ /** @var array */
+ protected $titles;
+
+ /** @var array */
+ protected $currentTitle;
+
+ function title($content, int $level=self::LEVEL_NORMAL): void {
+ if ($level < $this->minLevel) return;
+ $this->titles[] = [
+ "title" => $content,
+ "descs" => [],
+ "print" => true,
+ ];
+ $this->currentTitle =& $this->titles[count($this->titles) - 1];
+ }
+
+ function desc($content, int $level=self::LEVEL_NORMAL): void {
+ if ($level < $this->minLevel) return;
+ $this->currentTitle["descs"][] = $content;
+ }
+
+ protected function printTitles(): void {
+ $this->printSection();
+ }
+
+ protected function endTitle(): void {
+ array_pop($this->titles);
+ if ($this->titles) {
+ $this->currentTitle =& $this->titles[count($this->titles) - 1];
+ } else {
+ unset($this->currentTitle);
+ }
+ }
+
+ function print($content, int $level=self::LEVEL_NORMAL): void {
+ if ($level < $this->minLevel) return;
+ $this->printTitles();
+ $this->out->print($content);
+ }
+
+ /** @var array */
+ protected $actions;
+
+ /** @var array */
+ protected $currentAction;
+
+ function action($content, int $level=self::LEVEL_NORMAL): void {
+ $this->actions[] = [
+ "level" => $level,
+ "contents" => [$content],
+ "result" => null,
+ "print" => true,
+ ];
+ $this->currentAction =& $this->actions[count($this->actions) - 1];
+ }
+
+ function printActions(): void {
+ $this->printTitles();
+
+ }
+
+ function step($content): void {
+ $this->printActions();
+ }
+
+ function success($content=null): void {
+ $this->currentAction["contents"][] = $content;
+ $this->currentAction["result"] = true;
+ $this->printActions();
+ $this->endAction();
+ }
+
+ function failure($content=null): void {
+ $this->currentAction["contents"][] = $content;
+ $this->currentAction["result"] = false;
+ $this->printActions();
+ $this->endAction();
+ }
+
+ function neutral($content=null): void {
+ $this->currentAction["contents"][] = $content;
+ $this->currentAction["result"] = null;
+ $this->printActions();
+ $this->endAction();
+ }
+
+ protected function endAction(): void {
+ array_pop($this->actions);
+ if ($this->actions) {
+ $this->currentAction =& $this->actions[count($this->actions) - 1];
+ } else {
+ unset($this->currentAction);
+ }
+ }
+
+ function info($content, int $level=self::LEVEL_NORMAL): void {
+ if ($level < $this->minLevel) return;
+ $this->printActions();
+ }
+
+ function note($content, int $level=self::LEVEL_NORMAL): void {
+ if ($level < $this->minLevel) return;
+ $this->printActions();
+ }
+
+ function warn($content, int $level=self::LEVEL_NORMAL): void {
+ if ($level < $this->minLevel) return;
+ $this->printActions();
+ }
+
+ function error($content, int $level=self::LEVEL_NORMAL): void {
+ if ($level < $this->minLevel) return;
+ $this->printActions();
+ }
+
+ function end(bool $all=false) {
+ if ($all) {
+ while ($this->actions) $this->neutral();
+ while ($this->titles) $this->endTitle();
+ $this->endSection();
+ } elseif ($this->actions) {
+ $this->endAction();
+ } elseif ($this->titles) {
+ $this->endTitle();
+ } else {
+ $this->endSection();
+ }
+ }
+}
diff --git a/src/output/IMessenger.php b/src/output/IMessenger.php
new file mode 100644
index 0000000..1f33b36
--- /dev/null
+++ b/src/output/IMessenger.php
@@ -0,0 +1,87 @@
+ "0",
+ "bold" => "1",
+ "faint" => "2",
+ "underlined" => "4",
+ "reverse" => "7",
+ "normal" => "22",
+ "black" => "30",
+ "red" => "31",
+ "green" => "32",
+ "yellow" => "33",
+ "blue" => "34",
+ "magenta" => "35",
+ "cyan" => "36",
+ "white" => "37",
+ "default" => "39",
+ "black-bg" => "40",
+ "red-bg" => "41",
+ "green-bg" => "42",
+ "yellow-bg" => "43",
+ "blue-bg" => "44",
+ "magenta-bg" => "45",
+ "cyan-bg" => "46",
+ "white-bg" => "47",
+ "default-bg" => "49",
+ ];
+
+ const COLOR_MAP = [
+ "z" => "reset",
+ "o" => "black",
+ "r" => "red",
+ "g" => "green",
+ "y" => "yellow",
+ "b" => "blue",
+ "m" => "magenta",
+ "c" => "cyan",
+ "w" => "white",
+ "O" => "black_bg",
+ "R" => "red_bg",
+ "G" => "green_bg",
+ "Y" => "yellow_bg",
+ "B" => "blue_bg",
+ "M" => "magenta_bg",
+ "C" => "cyan_bg",
+ "W" => "white_bg",
+ "@" => "bold",
+ "-" => "faint",
+ "_" => "underlined",
+ "~" => "reverse",
+ "n" => "normal",
+ ];
+
+ /**
+ * @param resource|null $outf
+ * @throws Exception si la destination est un fichier et que son ouverture a
+ * échoué
+ */
+ function __construct($output=null, ?array $params=null) {
+ if ($output === null) $output = cl::get($params, "output");
+ $color = cl::get($params, "color");
+ $filterTags = cl::get($params, "filter_tags", true);
+ $flush = cl::get($params, "flush");
+
+ if ($output === null) {
+ $outf = STDOUT;
+ } elseif ($output === "php://stdout") {
+ $outf = STDOUT;
+ } elseif ($output === "php://stderr") {
+ $outf = STDERR;
+ } elseif (!is_resource($output)) {
+ # si $outf est un nom de fichier, vérifier que l'ouverture se fait sans
+ # erreur. à partir de là, plus aucune gestion d'erreur n'est faite
+ $outf = @fopen($output, "ab");
+ if ($outf === false) {
+ $error = error_get_last();
+ if ($error !== null) $message = $error["message"];
+ else $message = "$output: open error";
+ throw new Exception($message);
+ }
+ if ($flush === null) $flush = true;
+ } else {
+ $outf = $output;
+ }
+ if ($color === null) $color = stream_isatty($outf);
+ if ($flush === null) $flush = false;
+
+ $this->outf = $outf;
+ $this->color = boolval($color);
+ $this->filterTags = boolval($filterTags);
+ $this->flush = boolval($flush);
+ }
+
+ /** @var resource */
+ protected $outf;
+
+ /** @var bool faut-il autoriser la sortie en couleur? */
+ protected $color;
+
+ function isColor(): bool {
+ return $this->color;
+ }
+
+ /** @var bool faut-il enlever les tags dans la sortie? */
+ protected $filterTags;
+
+ /** @var bool faut-il flush le fichier après l'écriture de chaque ligne */
+ protected $flush;
+
+ function isatty(): bool {
+ return stream_isatty($this->outf);
+ }
+
+ private static function replace_colors(array $ms): string {
+ $colors = [];
+ foreach (preg_split('/\s+/', $ms[1]) as $color) {
+ while ($color && !cl::has(self::COLORS, $color)) {
+ $alias = substr($color, 0, 1);
+ $colors[] = self::COLOR_MAP[$alias];
+ $color = substr($color, 1);
+ }
+ if ($color) $colors[] = $color;
+ }
+ $text = "\x1B[";
+ $first = true;
+ foreach ($colors as $color) {
+ if (!$color) continue;
+ if ($first) $first = false;
+ else $text .= ";";
+ $text .= self::COLORS[$color];
+ }
+ $text .= "m";
+ return $text;
+ }
+ protected function filterContent(string $text): string {
+ # couleur au début
+ $text = preg_replace_callback('/]*)>/', [self::class, "replace_colors"], $text);
+ # reset à la fin
+ $text = preg_replace('/<\/color>/', "\x1B[0m", $text);
+ # enlever les tags classiques
+ if ($this->filterTags) {
+ $text = preg_replace('/<[^>]*>/', "", $text);
+ }
+ return $text;
+ }
+ protected function filterColors(string $text): string {
+ return preg_replace('/\x1B\[.*?m/', "", $text);
+ }
+
+ protected function fwrite(array $values): void {
+ $text = implode("", $values);
+ $text = $this->filterContent($text);
+ if (!$this->color) $text = $this->filterColors($text);
+ fwrite($this->outf, $text);
+ if ($this->flush) fflush($this->outf);
+ }
+
+ function write(...$values): void {
+ $this->fwrite($values);
+ }
+
+ function print(...$values): void {
+ $values[] = "\n";
+ $this->fwrite($values);
+ }
+}
diff --git a/src/output/msg/TODO.md b/src/output/TODO.md
similarity index 98%
rename from src/output/msg/TODO.md
rename to src/output/TODO.md
index 959bbb5..a8cd04d 100644
--- a/src/output/msg/TODO.md
+++ b/src/output/TODO.md
@@ -1,4 +1,4 @@
-# msg
+# IMessenger
* 3 niveaux: DEBUG, NORMAL, MAJOR
* plusieurs types de messages: