#!/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 );
}
}
?>