Cli.php

<?php

namespace Tlf;

class Cli {

    use Cli\Msgs;

    /**
     * Set true to run main instead of showing an error when the first arg is not a valid command.
     */
    public bool $main_on_command_not_found = false;

    /**
     * array<int index, string command_name> Array of commands to exclude from the help menu
     * @usage `$cli->exclude_from_help[] = 'main';`
     */
    public array $exclude_from_help = [];

    /**
     * The current working directory
     */
    public $pwd;

    /** map of command name to callable
     */
    public $command_map = [];

    /**
     * command_name=>help_msg
     */
    public $help_map = [];

    /**
     * the array of commands (for environments that have sub-commands)
     */
    // public $commands;
    
    /**
     * The command being executed
     */
    public $command = 'main';
    /**
     * The args to pass to your command's callable (key=>value array)
     */
    public $args = [];
    /**
     * The name of the command used to execute this (not path)
     */
    public $name;

    /**
     * Directory to write log files to.
     */
    public string $log_dir;

    /**
     * 
     */
    public $argv;

    public function __construct($pwd = null, $argv = null){
        $this->pwd = $pwd ?? $_SERVER['PWD'];
        $this->argv = $argv ?? $_SERVER['argv'];
        $this->load_command('help', [$this, 'help_menu'], 'show this help menu');
        $this->help_map['main'] = 'When the script is called with no arguments';
        $this->name = basename($this->argv[0]);
    }

    /**
     * Log $message to disk with a date & timestamp. You must set `$cli->log_dir` to a valid directory.
     *
     * @param $message a message to log. New lines are padded, and it is prefixed with a date & time.
     * @param $log_file the name of a file to log to inside `$cli->log_dir`. The file does not need to exist prior to logging.
     *
     * @return void does not return anything
     */
    public function log(string $message, string $log_file = 'tlf-main'){
        if (!isset($this->log_dir)){
            echo "\nERROR: Cannot log. log_dir not set.\n";
            return;
        }
        $fh = fopen($this->log_dir.'/'.$log_file,'a');
        if ($fh===false){
            echo "\nERROR: Cannot log to file $log_file. fopen() failed.\n";
            return;
        }
        $date = new \DateTime();
        $date_string = $date->format('Y-m-d H:i:s T');

        $parts = explode("\n", trim($message));
        $parts = array_map('trim', $parts);
        $padded_message = implode("\n    ", $parts);

        $write_message = "[$date_string]: $padded_message\n";

        fwrite($fh, $write_message);

        fclose($fh);
    }

    /**
     * load standard inputs from cli
     * @param $stdin (optional) array of arguments like `['filename', 'subcommand', '-option', 'option_value', '--flag', 'etc']`
     * @default $stdin to $_SERVER['argv']
     */
    public function load_stdin($stdin=null){
        $stdin = $stdin ?? $this->argv;
        $this->script = basename($stdin[0]);
        if (!isset($stdin[1])||substr($stdin[1],0,1)=='-'){
            $this->command = $this->command;
            $slice = 1;
        } else {
            $this->command = $stdin[1];
            $slice = 2;
        }
        $args = $this->parse_args(array_slice($stdin,$slice), $this->args);
        $this->args = array_merge($this->args, $args);
    }

    public function parse_args($stdin_args, $args=[]){
        $last_key = null;
        foreach ($stdin_args as $arg){
            if ($arg[0]=='-'){
                if ($arg[1]=='-'){
                    $last_key= substr($arg,2);
                    $args[$last_key] = true;
                    continue;
                }
                $last_key = substr($arg,1);
                if (!isset($args[$last_key])){
                    $args[$last_key] = null;
                } 
            } else if ($last_key==null){
                $args['--'][] = $arg;
            } else {
                if ($arg==='true')$arg = true;
                else if ($arg === 'false')$arg = false;
                if (is_array($args[$last_key])){
                    $args[$last_key][] = $arg;
                } else {
                    $args[$last_key] = $arg;
                }
                $last_key = null;
            }
        }

        return $args;
    }

    /**
     * @param $command the command 
     * @param $callable Ex: `function(\Tlf\Cli $cli, array $args){}`
     * @param $help_msg short description for help menu
     */
    public function load_command(string $command, $callable, string $help_msg=''){
        $this->command_map[$command] = $callable;
        if ($help_msg!='')$this->help_map[$command] = $help_msg;
    }

    /**
     * Load a json settings file, if it exists. Fails silently if it does not exist.
     *
     * @param string $file
     * @return array of data from the file, or false on failure
     */
    public function load_json_file(string $file){
        if (!file_exists($file))return false;
        $content = file_get_contents($file);
        $settings = json_decode($content, true);
        $this->load_inputs($settings);
        return $settings;
    }
    public function load_inputs($args){
        $this->args = array_merge($this->args, $args);
    }

    /**
     *
     * Execute a command by name
     *
     * @param $command the name of the command to execute
     * @param $args args to execute
     */
    public function call_command(string $command, array $args=[]){
        if (!isset($this->command_map[$command])){
            if ($this->main_on_command_not_found){
                if (!isset($args['--']))$args['--'] = [];
                
                array_unshift($args['--'], $command);
                $command = 'main';
            } else {
                echo 'Command "'.$command.'" does not exist.'."\n";
                echo "Try \"$command help \".";
                return;
            }
        }
        $call = $this->command_map[$command];
        $ret = $call($this, $args);
        echo "\n";
        return $ret;
    }

    /**
     * Run the cli based on loaded inputs
     */
    public function execute(){
        return $this->call_command($this->command, $this->args);
    }

    public function help_menu($cli, $args){
        foreach ($this->command_map as $command => $c){
            // if ($command=='main')continue;
            if (array_search($command, $this->exclude_from_help) === false){
                echo "\n    $command - ". ($this->help_map[$command]??'');
            }
        }
        echo "\n";
    }

    /**
     * Prompt user for y/no answer. Lowercase 'y' is yes. All other answers are no.
     *
     * @param $msg string message to display (newline added before, question mark after)
     * @param $success_func (optional) a callback that receives ...$args if user answers 'y'.
     * @param ...$args, args to pass to the success function
     *
     * @return true if user answers 'y', false otherwsie.
     */
    public function ask(string $msg, $success_func=null,...$args){
        $answer = readline("\n".$msg."(y/n)? ");
        if (strtolower($answer)!='y')return false;
        if ($success_func==null)return true;
        $success_func(...$args);
        return true;
    }

    /**
     * Prompt user for string input
     *
     * @param $msg string message to display (newline added before, colon after)
     *
     * @return user's input, trimmed.
     */
    public function prompt(string $msg){
        $answer = readline("\n".$msg.": ");
        return trim($answer);
    }
    /**
     * Print an array as a table, just like mysql cli does. all values will be printed. column width will be fixed to the maximum length
     *
     * @param $rows array<string key, mixed value> keys will be printed as headers. 
     */
    public function print_table(array $rows){
        $stats = [];
        foreach ($rows as $rownum=>$row){
            foreach ($row as $key=>$value){
                $cur_len = $stats[$key] ?? 0;
                if (($new_len = strlen($value)) > $cur_len)$stats[$key] = $new_len;
                if (($keylen = strlen($key)) > $new_len) $stats[$key] = $keylen;
            }
        }

        $total_len = 0;
        $lines = ["","",""];
        $last_line = '';
        foreach ($stats as $key=>$len){
            $pluspart = "+". str_repeat('-',$len + 2);
            $lines[0] .= $pluspart;
            $lines[1] .= "| $key ";
            $lines[2] .= $pluspart;
            $last_line .= $pluspart;
            if (($diff = ($len - strlen($key) ) ) >0)$lines[1] .= str_repeat(' ',$diff);
        }

        $lines[0] .= '+';
        $lines[1] .= '|';
        $lines[2] .= '+';

        foreach ($lines as $line){
            echo "\n".$line;
        }

        foreach ($rows as $row){
            echo "\n";
            foreach ($row as $key=>$value){
                $value = str_replace("\n", "\\n", $value);
                echo "| ".$value .' ';
                $maxlen = $stats[$key] ?? 0;
                $thislen = strlen($value);
                if (($diff = ($maxlen - $thislen)) > 0) echo str_repeat(" ", $diff);
            }
            echo "|";
        }

        echo "\n{$last_line}+";
        echo "\n";


// EXAMPLE ... except we're not right-justifying nulls & numbers
//+----+---------------+-------------+--------------+---------------------+---------+--------------------+
//| id | title         | description | slug         | created_at          | status  | related_article_id |
//+----+---------------+-------------+--------------+---------------------+---------+--------------------+
//|  1 | One           | Desc 1      | one          | 2023-11-27 15:31:01 | public  |               NULL |
//|  2 | Two           | Desc 2      | two          | 2023-11-27 15:31:01 | public  |               NULL |
//|  3 | Three         | Desc 3      | three        | 2023-11-27 15:31:01 | public  |                  1 |
//|  4 | Four, Private | Desc 4      | four-private | 2023-11-27 15:31:01 | private |                  1 |
//+----+---------------+-------------+--------------+---------------------+---------+--------------------+

    }
}