Command line tool to duplicate/modify version number strings across source code and distribution files according to syntax context.

⌈⌋ ⎇ branch:  version numbers get/write


Artifact [78132fedda]

Artifact 78132fedda9190a9685e36519bef39dc0c640958:

  • Executable file version.php — part of check-in [b62946f7b1] at 2020-10-31 21:18:33 on branch trunk — Add `static` to class functions (php8), revert to \r\n in place of \R for older libpcre versions. (user: mario size: 19089)

#!/usr/bin/php
<?php
/**
 * type: cli
 * title: Version Get/Write
 * description: Commandline tool to read and update source code version numbers
 * version: 3.2.7-0
 * category: tools
 * 
 *
 * 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 various source and target
 * syntax variations:
 *
 *   PHP plugins   # version: 1.2.3       RPM/EPM spec    %version 7.1
 *   PHP docblock  * @version: 0.41       Debian control  Version: 2.0-3
 *   Python/INI    version = 5.3          Constants   const VERSION = '2.0'
 *   Script vars   $version = "1.0";      JSON    { "version": "0.2.2" }
 *
 * 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 and bump then  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 a - single or -- double dash, as in --get.
 * The order of filenames and actions is often irrelevant.
 *
 *   --get    Loads and shows version from file.
 *   --read   Just loads version, does not display it.
 *   --show   Just prints out current or increased/bumped version number.
 *
 *   --write  Update version field in the destination source code file.
 *
 *   --incr   Increment patch ver number (last colon-separated 0.0.x decimal)
 *   --bump   Add/count up packaging/suffix (first num after dash 0.0.0-x)
 *            You can also bump in bigger spans using --bump::2 or --incr::3
 *
 *   --format:ini     Explicit file format
 *   --format:php:2   Also specify how many occurences to replace.
 *
 *   --rx:name:.+     Add a custom content regex.
 *
 * You can add the :format:count extras to any command actually, so `-fmt:py:2`
 * is used in `-write:py:2` implicitly.
 * More aliases exist for actions, for example ++ for -increment.
 * For format types you can specify both file extension and internal rx format
 * monikers (ini, var, const, ... or `_raw_` for any versiony-looking string).
 *
 * Often you may wish to split up invocations from the shell, as in:
 *
 *    VERSION=$(version get orig.c ++)
 *    version write $VERSION dest.cpp
 *
 * There's no commandline option for custom regexen, to eschew code duplication
 * in said shell scripts.
 *
 *@version 3.2.7-0
 */         



// Tool-specific data
class cfg {

 
    #-- Regular expressions
    
    // Generic version matching with (?&ver)
    static $rx_ver = "(  (?<epoch>\d+:)? (?<major>\d+) \.(?<minor>\d+) (?:\.(?<patch>\d+))? (?<suffix>[\d\w\-+.~_]*)  )";

    // Whitespace without linebreaks,
    // will be applied in place of `\s` in the following rx_format list
    static $rx_ws  = "[^\S\\r\\n]";  # mostly like `\h`

    // Default regex end match (?&end)
    static $rx_end = " \s* \r?$";

    // Filetype/language-specific context matching
    static $rx_format = array(
         // Debian package description
        "debian"   =>       "^ Version: \s+ ",
         // RPM package description
        "rpmspec"  =>       "^ % version \s+",
         // Generic meta data comment block
        "plugin"   =>       "^ \s* (?:\*|\#|\/\/) \s* (?i) version: \s* ",
         // Documentation
        "docblock" =>       "^ \s* [*] \s? @version \s+",
         // Constant declarations
        "const"    => array("^ \s* const \s+ \w*VERSION \s+ = \s+ [\'\"]",  '(?&ver)',   "[\'\"]; (?&end)"),
         // PHP constant declarations
        "define"   => array("^ \s* define \( [\'\"] \w*VERSION [\'\"], \s* [\'\"]",  '(?&ver)',  "[\'\"]\); (?&end)"),
         // Scripting language variable or property assignments
        "var"      => array("^ \s* (?:var|public|private|protected|static|global)? \s* \$\w*version \s* = \s* [\'\"]",  '(?&ver)',  "[\'\"]; (?&end)"),
         // Powershell param
        "ps1"      => array(" (?i)  \$ VERSION \s* = \s* [\'\"]",  '(?&ver)', "[\'\"]"),
         // Wordpress plugin meta data
        "wordpress"=>       "^ \s* Version: \s* ",
         // Config.ini files
        "ini"      => array("^ \s* version \s* = \s* [\'\"]?",  '(?&ver)',  "[\'\"]? (?&end)"),
         // Python module versions
        "python"   => array("^ \s* __version__ \s* = \s* [\'\"]",  '(?&ver)',  "[\'\"] (?&end)"),
         // JSON dict as used by PHP composer
        "composer" => array("[{,] \s* \"version\" \s* : \s* \" ",  '(?&ver)',  " \"  \s* [,}] "),
         // Single XML tag
        "xmltag"   => array("<version>\s*", '(?&ver)', "\s*<\/version>"),
        "xmlval"   => array("<version\s+value=\"", '(?&ver)', "\"\s*\/?>"),
         // Would match ANYTHING in a text file that looks like a version number.
        "_raw_"    => array("", '(?&ver)', "")
    );


    // Map file extensions to regex format names
    static $ext_to_rxformat = array(
        "php" => array("plugin", "docblock", "define", "const", "var", "wordpress"),
        "control" => array("debian"),
        "py" => array("python", "plugin", "ini"),
        "cpp" => array("plugin", "const"),
        "c" => array("plugin", "ini"),
        "ps1" => array("ps1", "plugin", "var"),
        "json" => array("composer"),
        "sh" => array("plugin", "ini"),
        "xml" => array("xmltag", "xmlval"),
        "spec" => array("rpmspec"),
        "list" => array("rpmspec"),
       #"txt" => array("_raw_"),  // e.g. distutils2 version.txt
        "any" => array("debian","rpmspec","plugin","docblock","const","define","var","wordpress","ini","python","composer","xmltag","ps1"),
    );


    // Binary file format handlers (see action:: class)
    static $bin_handlers = array(
        "phar" => "bin_phar",
        "deb" => "bin_deb",
    );


    // Command line aliases
    static $action_aliases = array (
        "write" => "write",  "save" => "write",  "update" => "write",
        "get" => "get",  "from" => "get",
        "read" => "read",  "fetch" => "read",
        "show" => "show",  "echo" => "show",  "print" => "show",
        "increment" => "increment",  "++" => "increment",  "add" => "increment",  "inc" => "increment",  "incr" => "increment",
        "bump" => "bump",  "suffix" => "bump",  "release" => "bump",
        "format" => "format",  "fmt" => "format",   "and" => "format",  "then" => "format",  "set" => "format",
        "rx" => "rx", "regex" => "rx",
        "last" => "last",  "lastfn" => "last",
        "help" => "help",
        "dry" => "dry",  "nowrite" => "dry",  "dryrun" => "dry",
    );

}//cfg




#-- Most commands require a pair of $action + $file to be run
$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 argument list
$argv0 = basename($_SERVER["argv"][0]);
$argv = array_slice($_SERVER["argv"], 1);

// no args = help
if (empty($argv)) {
    $argv[] = "help";
}

// Use get_ or write_ prefix from invocation basename as first action
elseif (strpos($argv0, "_")) {
    $cmd->action = key(array_intersect_key(array_flip(explode("_", $argv0)), cfg::$action_aliases));
}

// Just one filename arg = use default action 'get'
elseif (count($argv) == 1) {
    $cmd->action = "get";
}





#-- Iterate over argument list
foreach ($argv as $arg) {


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

    // STDIN
    elseif ($arg == "-") {
        $cmd->file = "php://stdin";
    }

    // Reuse previous filename and options (but retain current action)
    elseif (ltrim($arg, "-") == "last") {
        $last->action = $cmd->action;
        $cmd = $last;
    }
    
    // Input version number
    elseif (preg_match("/^".cfg::$rx_ver."$/x", $arg)) {
        if (!rx::is_valid_ver($arg)) { error("Invalid version string supplied '$arg'", 33 /*EDOM*/); }
        $cmd->version = $arg;
    }

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



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

        // Output current $cmd->version
        case "show":
            print $version;
            break;

        // Read and print from file
        case "get":
            if ($cmd->file) {
                action::read($cmd);
                if (!rx::is_valid_ver($cmd->version)) {
                    // return 0.0-0error on failure
                    fwrite(STDOUT, "0.0-0error");
                    error("No version number found in file '$cmd->file'", 61/*ENODATA*/);
                    // (while --read should not not)
                }
                print $cmd->version;
                $last=$cmd and $cmd=new command();
            }
            break;

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

        // Update current $version string into target source code
        case "write":
            if ($cmd->file) {
                if (!rx::is_valid_ver($version)) {
                    // fail
                    error("No valid version number to write. Aborting.", 33 /*EDOM*/);
                }
                action::write($cmd);
                $last=$cmd and $cmd=new command();
            }
            break;

        // Increment patch version number 0.0.x or 0.0.0.x
        case "increment":
            action::increment($cmd);
            break;

        // Increment or add release suffix 0.0-1
        case "bump":
            action::bump($cmd);
            break;
            
        // Make 'write' command non-writey
        case "dry":
            $cmd->dry = 1;
            break;

        // Allow custom regexen after all
        case "rx":
            cfg::$rx_format[$cmd->format] = array($cmd->count, '(?&ver)', "");
            $cmd->count = 1;
            break;

        // NOP
        case "":
        case "format":
            // do nothing (--format:TYPE:N params can be attached to any other command, --format itself incurs no action)
            break;

        // Show top level documentation as help
        case "help":
            action::help();
            break;

        // Should've been catched earlier (= an assert() would do here)            
        default:
            error("Unimplemented action '$cmd->action'\n", 24 /*EINVAL*/);
    }
}



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



    /**
     * Read file, and preg_match() for version number.
     *
     */
    static 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) {
#print "trying $rx …\n";
#print substr($src, 0, 1024);
            if (preg_match($rx, $src, $match)) {
                $cmd->version = $match["version"];
                return;
            }
        }
        
        //ooops
        
    }



    /**
     * Read file, and preg_match() for version number.
     *
     */
    static 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
     *
     */
    static 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
     */
    static 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";
        }
    }



    /**
     * Print out file comment as --help
     *
     */
    static function help() {
        $src = file_get_contents(__FILE__) and preg_match("#/\*\*.+?\*/#s", $src, $m) and $src = $m[0];
        $src = preg_replace("#^/\*+\s*$|^\s*\*/$|^ \*(@version.+)? ?#m", "", $src);
        $src = preg_replace(
            array("/^\w+:|--/m",       "/version/",       "/get|show|read|write|incr|bump|format/", "/[a-z]\w+\.\w+/", "/:/"),
            array("\33[30;1m$0\33[0m", "\33[32m$0\33[0m", "\33[31m$0\33[0m",                        "\33[36m$0\33[0m", "\33[33;1m$0\33[0m"),
            $src
        );
        die($src);
    }            



    #-- binary handlers


    
    // read "version" meta field via binary helpers
    static function bin_read($cmd) {
        if (isset(cfg::$bin_handlers[$cmd->ext])) {
            $cmd->version = "";  // unset ->version so it becomes a read action
            return self::bin_write($cmd);
        }
    }

    
    // write "version" meta field to binary
    static function bin_write($cmd) {
        if (isset(cfg::$bin_handlers[$cmd->ext])) {
            $h = cfg::$bin_handlers[$cmd->ext];
            if (method_exists("action", $h)) {
                call_user_func(array("action",$h), $cmd);
                return TRUE;
            }
        }
    }

    
    // .phar files
    static 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
    }
    
    
    
    // .deb archives (read-only)
    static function bin_deb($cmd) {

        // read    
        if (empty($cmd->version)) {
            $fn = escapeshellarg($cmd->file);
            $src = `ar p {$fn} control.tar.gz | tar xOz ./control`;
            $rx = rx::combine(array(cfg::$rx_format["debian"]));
            if (preg_match($rx[0], $src, $m)) {
                $cmd->version = $m["version"];
            }
        }
        else {
            error("Cannot update .deb archives.", 1 /*EPERM*/);
        }
    }

}




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


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


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

        // 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(cfg::$rx_format[$s])) {
                $rx[] = cfg::$rx_format[$s];
            }
            elseif (isset(cfg::$ext_to_rxformat[$s])) {
                foreach (cfg::$ext_to_rxformat[$s] as $s2) {
                    $rx[] = cfg::$rx_format[$s2];
                }
            }
            else {
                error("Unkown file extension or rx_format '$s' name", 65 /*ENOPKG*/);
            }
        }
        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.
     *
     */
    static function combine($prefixes) {

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

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



    /**
     * Version string conformance testing.
     *
     *  Applies $rx_ver obviously,
     *  but also checks for `0.0-0error` specials (which --get may have returned on previous runs).
     *
     */
    static function is_valid_ver($version) {
        return preg_match("/^(".cfg::$rx_ver.")$/x", $version)
           and !preg_match("/^0\.0([0.-]*|[12]?error)+$/", $version);
    }


}



/**
 * Abort.
 *
 *
 */
function error($msg="aborted", $errno=-1) {
    fwrite(STDERR, basename($_SERVER['argv'][0]) . ": " . $msg . "\n");
    if ($errno > 0) {
        exit($errno   );
    }
}


?>