#!/usr/bin/php
<?php
/**
 * type: cli
 * title: Version Get/Write
 * description: Commandline tool to read and update source code version numbers
 * version: 3.1.5-3
 * 
 *
 * This tool reads various formats of "Version: x.y*" from and to source code
 * or distribution files. It's useful for keeping them synced or prepared without
 * further packaging tool scripting.
 *
 * It's indifferent to versioning schemes, understands following source and target
 * text variations:
 *
 *   PHP plugins   # version: 1.2.3       RPM/EPM spec    %version 7.1
 *   PHP docblock  * @version: 0.0        Debian control  Version: 2.0-3
 *   Python src    version = 
 *   Script vars   $version = "1.0";
 *
 * Multiple commands are supported and can be chained often quite readibly to
 * move version numbers across files:
 *
 *   version  get file.php
 *
 *   version  read file.c  bump  show
 *
 *   version  --get file.php   --incr  --write package.list
 *
 *   version  read script.py  incr  bump   write DEBIAN/control
 *
 *   version  read:php file.php  bump  write:spec package.spec
 *
 *   version  1.2.3-dev  write  source.php
 *
 *   version  get  phar://project.phar/.phar/stub.php   write project.phar
 *
 *
 * Everything that's not a valid filename is presumed to be an action command,
 * and can also be prefixed with --, as in --get. The order of filenames and
 * actions is often irrelevant.
 *
 *   --get    Loads and shows version.
 *   --read   Just loads version from file, does not display it.
 *   --show   Just prints out current or increased/bumped version number.
 *
 *   --write  Update version field in target file. Here it's often sensible to
 *            specify the target filename beforehand.
 *
 *   --incr   Increment patch ver number (last colon-separated 0.0.x decimal)
 *   --bump   Add/incr packaging/suffix (first num after dash 0.0.0-x)
 *            You can also bump in bigger spans using --bumb::2 or --incr::3
 *
 *   --format:ini     Explicit file format
 *   --format:php:2   Also specify how many occurences to replace.
 *
 * Some aliases exist for actions, for example ++ for --increment.
 * ...
 * There's no commandline option for custom regexen, to eschew code duplication.
 *
 *@version 3.1.5-3
 */


 
#-- regular expressions
$rx_ver        = "(  (?<epoch>\d+:)? (?<major>\d+) \.(?<minor>\d+) (?:\.(?<patch>\d+))? (?<suffix>[\d\w-+.~_]*)  )";
$rx_ws  =  $s  = "[^\S\R]";    // whitespace without linebreaks
$rx_end        = " $s* $";   // default regex end match (everything after the $rx_ver match)
$rx_format = array(
    "debian"   =>       "^ Version: $s+ ",
    "plugin"   =>       "^ $s* (?:\*|\#|\/\/) $s* (?i) version: $s* ",   // generic meta data comment block
    "docblock" =>       "^ $s* [*]@version $s+",
    "const"    => array("^ $s* const $s+ \w*VERSION $s+ = $s+ [\'\"]",  $rx_ver,   "[\'\"]; $rx_end"),
    "define"   => array("^ $s* define \( [\'\"] \w*VERSION [\'\"], $s* [\'\"]",  $rx_ver,  "[\'\"]\); $rx_end"),
    "var"      => array("^ $s* (?:var|public|private|protected|static|global)? $s* \$\w*version $s* = $s* [\'\"]",  $rx_ver,  "[\'\"]; $rx_end"),
    "wordpress"=>       "^ $s* Version: $s* ",
    "ini"      => array("^ $s* version $s* = $s* [\'\"]",  $rx_ver,  "[\'\"] $rx_end"),
    "composer" => array("$s* \"version\" $s* : $s* \" ",  $rx_ver,  " \"  $s* [,}] "),
    "xmltag"   => array("<version>$s*", $rx_ver, "$s*<\/version>"),
    "_raw_"    => array("", $rx_ver, "")  // would match anything in a text file that looks like a version number
);

#-- config
$action_aliases = aliases_flip(array(
    "write" => array("save", "write", "update"),
    "get" => array("get"),
    "read" => array("read"),
    "show" => array("show"),
    "increment" => array("++", "add", "inc", "incr", "increment"),
    "bump" => array("bump", "suffix", "release",),
    "format" => array("fmt", "format", "and", "then", "set"),
    "last" => array("last"),
    "help" => array("help"),
    "dry" => array("dry", "nowrite", "dryrun"),
));
$ext_to_rxformat = array(
    "php" => array("plugin", "docblock", "define", "const", "var", "wordpress"),
    "control" => array("debian"),
    "py" => array("plugin", "ini"),
    "cpp" => array("plugin", "const"),
    "c" => array("plugin", "ini"),
    "json" => array("composer"),
    "xml" => array("xmltag"),
    "any" => array_keys(array_filter($rx_format)),
);
$bin_handlers = array(
    "phar" => "bin_phar",
);



#-- most actions require a pair of $action and $file to be set
$version = "";
class command {
    var $action = "";
    var $file = "";
    var $ext = "";
    var $format = "";
    var $count = 1;
    function __construct() { $this->version = & $GLOBALS["version"]; }
    var $version;
}
$cmd = new command();
$last = new command();



#-- init argv
$argv = array_slice($_SERVER["argv"], 1);
// no args = help
if (empty($argv)) {
    $argv = array("help");
}
// just one filename arg = default action to 'get'
elseif (count($argv) == 1) {
    $cmd->action = "get";
}



#-- go over args list
foreach ($argv as $arg) {


    #-- categorize current argument
    
    // it's a filename
    if (file_exists($arg)) {
        $cmd->file = $arg;
        $cmd->ext = trim(substr($arg, strrpos($arg, ".")), ".");   // extension or basename (e.g. for DEBIAN/control file)
    }

    // special filename
    elseif ($arg == "-") {
        $cmd->file = "php://stdin";
    }
    
    // input version number
    elseif (preg_match("/^$rx_ver$/x", $arg)) {
        $cmd->version = $arg;
    }

    // action/command name
    else {
    
        // extra flags
        if (strpos($arg, ":")) {
            list($arg, $cmd->format, $cmd->count) = explode(":", "$arg:1");
        }
        
        // normalize
        $cmd->action = $action_aliases[ltrim($arg, "-")]
        or exit("version: Unknown action command or non-existant file '$arg'\n");
    }



    #-- processing/behaviour
    switch ($cmd->action) {

        case "show":
            print $version;
            break;

        case "get":
            if ($cmd->file) {
                action::read($cmd);
                if (strlen($cmd->version) < 3) {
                    // return 0.0-0error on failure
                    // (while --read should not not)
                }
                print $cmd->version;
                $last=$cmd and $cmd=new command();
            }
            break;

        case "read":
            if ($cmd->file) {
                action::read($cmd);
                $last=$cmd and $cmd=new command();
            }
            break;

        case "write":
            if ($cmd->file) {
                if (strlen($cmd->version) < 3) {
                    // error
                }
                action::write($cmd);
                $last=$cmd and $cmd=new command();
            }
            break;

        case "increment":
            action::increment($cmd);
            break;

        case "bump":
            action::bump($cmd);
            break;

        case "dry":
            $cmd->dry = 1;
            break;

        case "":
        case "nop":
        case "format":
            // do nothing (--format:TYPE:N params can be attached to any other command, --format itself incurs no action)
            break;
            
        case "help":
            die(file_get_contents(__FILE__, NULL, NULL, 20, 2100));
            
        default:
            exit("version: Unimplemented action '$cmd->action'\n");
    }
}



/**
 * Action implementation
 *
 *
 */ 
class action {



    /**
     * Read file, and preg_match() for version number.
     *
     */
    function read($cmd) {
    
        #-- binary
        if (self::bin_read($cmd)) {
            return;
        }

        #-- text, read src
        $src = file_get_contents($cmd->file);

        // match
        foreach (rx::get($cmd) as $rx) {
            if (preg_match($rx, $src, $match)) {
                $cmd->version = $match["version"];
                return;
            }
        }
        
        //ooops
        
    }


    /**
     * Read file, and preg_match() for version number.
     *
     */
    function write($cmd) {

        #-- binary
        if (self::bin_write($cmd)) {
            return;
        }

        #-- text, read src
        $src = file_get_contents($cmd->file);
        
        // replacement limit
        $limit = $cmd->count ? (int)$cmd->count : 1;
        //@bug: PHP applies the limit to each regex in the list, not as overall

        // replace
        $src = preg_replace_callback
            (
                rx::get($cmd),
                function($m) use ($cmd, $limit) {
                
                    // apply application stop here, because preg_replace() only does for *each* rx[] list entry
                    if ($limit-- <= 0) {
                        return $m[0];
                    }
                    
                    // replace 
                    return( $m["begin"] . $cmd->version . $m["end"] );
                },
                $src,
                $limit
            );
            
        // dry run?
        if (isset($cmd->dry)) {
            die("$src");
        }
        else {
            file_put_contents($cmd->file, $src);
        }
    }


    /**
     * Increment patch 0.0.x or minor version number 0.y
     *
     */
    function increment($cmd) {
        $cmd->version = preg_replace_callback("/(?<=\.) (\d+) (?=[^.]*$)/x", function($m) use ($cmd) {
            return $m[1] + $cmd->count;
        }, $cmd->version);
    }



    /**
     * Increment -1 release suffix, or add one.
     *
     * @todo: support $cmd->dist for e.g. -0vendor2
     */
    function bump($cmd) {
        if (preg_match("/-\d+/", $cmd->version)) {
            $cmd->version = preg_replace_callback("/(?<=-) (\d+)/x", function($m) use ($cmd) {
                return $m[1] + $cmd->count;
            }, $cmd->version);
        }
        else {
            $cmd->version .= "-0";
        }
    }





    #-- binary handlers


    
    // read "version" meta field via binary helpers
    function bin_read($cmd) {
        $cmd->version = "";  // unset ->version so it becomes a read action
        return self::bin_write($cmd);
    }
    
    // write "version" meta field to binary
    function bin_write($cmd) {
        global $bin_handlers;
        if (isset($bin_handlers[$cmd->ext])) {
            $h = $bin_handlers[$cmd->ext];
            if (method_exists("action", $h)) {
                call_user_func(array("action",$h), $cmd);
                return TRUE;
            }
        }
    }
    
    // .phar files
    function bin_phar($cmd) {

        // open and get old meta block
        $p = new Phar($cmd->file);
        $m = $p->getMetadata();

        // read
        if (empty($cmd->version)) {
            $cmd->version = $m["version"];
        }
        // update
        else {
            $m["version"] = $cmd->version;
            $p->setMetadata($m);
        }
        // same as:
        //    phar meta-get -f filename.phar -k version
    }

}




/**
 * Regex lookup and preparation
 * *
 */ 
class rx {

    // turn list of formats or ext specifiers into regex list
    function get($cmd) {
        return rx::combine( rx::formats($cmd) );
    }

    /**
     * Return ordered list of regular expression to try.
     * A specific ->type overrides the ->ext defaults.
     *
     */
    function formats($cmd) {

        global $rx_format;    # list of regex
        global $ext_to_rxformat;  # indirect reference
        
        // request a specific rx_format or file extension
        if ($cmd->format) {
            $search = explode(",", $cmd->format);
        }
        // base on file extensions
        elseif ($cmd->ext) {
            $search = array($cmd->ext);
        }
        // use anything
        else {
            $search = array("any");
        }
        
        // unpack remaining file extensions into rx_format names
        $rx = array();
        foreach ($search as $s) {
            if (isset($rx_format[$s])) {
                $rx[] = $rx_format[$s];
            }
            elseif (isset($ext_to_rxformat[$s])) {
                foreach ($ext_to_rxformat[$s] as $s2) {
                    $rx[] = $rx_format[$s2];
                }
            }
            else {
                die("version: Unkown file extension or rx_format '$s' name\n");
            }
        }
        return $rx;
    }
    
    /**
     * Combine a list of regex parts with $rx_ver, $rx_end
     *
     *   / ($BEGIN)  ($rx_ver)  ($END | $rx_end) /mx
     *
     * Return list, to be readily used in preg_match or _replace.
     *
     */
    function combine($prefixes) {
        global $rx_ver, $rx_end, $rx_ws;

        // patch together
        foreach ($prefixes as $i=>$parts) {

            // parts
            list($begin, $ver, $end) = is_array($parts)
                ? $parts
                : array($parts, $rx_ver, $rx_end);
        
            // wrap
            $prefixes[$i] = "/ (?<begin>$begin) (?<version>$ver) (?<end>$end) /mx";
        }
        
        return array_unique($prefixes);
    }

}





/**
 * Unpack aliases into flat list.
 *   asvalue => [newkey1, newkey2, newkey3]
 *
 */
function aliases_flip($from) {
    $r = array();
    foreach ($from as $key=>$values) {
        $r[$key] = $key;
        foreach ($values as $v) {
            $r[$v] = $key;
        }
    }
    return $r;
}

?>