#!/usr/bin/php -qC
<?php
 #
 # type: cli
 # description: Transform "type hints" into function parameter type assert() statements.
 # version: 0.1.0
 #
 # Duplicates parameter value-type hints into static lists of assertions atop
 # each function body. Accepts in-comment "type hints". Can also remove them
 # again. It recognizes scalar-ish types, arrays and resources:
 #
 #    function xyz (/*int*/ $a, /*string*/ $s, /*bool*/ $b)
 #    {
 #        assert('is_int($a) /*typehint*/');
 #        assert('is_string($s) || $s instanceof SplString /*typehint*/');
 #        assert('is_bool($b) /*typehint*/');
 #
 # Even allows a few basic expressions or comparisons within assertions:
 #
 #    function cmp (/*int,>0*/ $i, /*str,strlen($)<32*/, $s)
 #
 # Such type assertions can be turned off at runtime, be configured to generate
 # E_WARNINGs instead, or permit custom handlers. Can be selectively displaced
 # once PHP7 "type hints" become available (implicit type casts or runtime errors
 # preclude any traceable assertions of course).
 #
 # To add assertions:
 #   php-assert-hints files/*.php
 #
 # Remove them:
 #   php-assert-hints --rm files/*.php
 #


// Loop over input files
list($files, $opts) = argv();
$files or exit("Usage:\n  php-assert-hints *.php\n");
array_map(
    [new FuncDeclRewriteMap($opts), "on"],
    $files
);


/**
 * Transforms input files
 *
 */
class FuncDeclRewriteMap {

    // Parameter types to assert conditions, some aliases
    public $types = [
        "int" => "is_int($)",
        "integer" => "is_integer($)",
        "long" => "is_long($)",
        "bool" => "is_boolean($)",
        "boolean" => "is_boolean($)",
        "str" => "is_string($) || $ instanceof SplString",
        "string" => "is_string($) || $ instanceof SplString",
        "float" => "is_float($)",
        "real" => "is_real($)",
        "double" => "is_double($)",
        "numeric" => "is_numeric($)",
        "array" => "is_array($) || $ instanceof Traversable || $ instanceof ArrayObject",
        "scalar" => "is_scalar($)",
        "resource" => "is_resource($) || $ instanceof PDO",
        "callable" => "is_callable($)",
        "isset" => "isset($)",  # only makes sense for references
        "=null" => " || is_null($)",  # added for =NULL defaults
    ];
    public $only_rm = false;


    // Retain --rm cmdline flag
    public function __construct (/*array*/ $opts) {
        $this->only_rm = preg_grep("/^-+(rm?|de?l?)$/i", $opts);
    }
    

    // Update file in-place
    public function on (/*string*/ $fn) {

        $src = file_get_contents($fn);
        if ($update = $this->rewrite($src)) {
        
            // check that only lines containing assert() have been added/removed/changed
            $diff = array_diff(preg_split("/\R/", $src), preg_split("/\R/", $src));
            if (preg_grep("/^\s*assert\(/mi", $diff, PREG_GREP_INVERT)) {
                fwrite(STDERR, "Invalid rewrite (changed too much) for '$fn'\n");
            }
            
            // write back
            else {
                file_put_contents($fn, $update);
            }
        }
        
        elseif (preg_last_error()) {
            fwrite(STDERR, "Probable regex/UTF8 error with '$fn'\n");
        }
    }


    // Update source
    public function rewrite(/*string*/ $src) {
    
        // remove prior assertions
        $src = preg_replace(self::RX_ASSERT, "", $src);
        if ($this->only_rm) {
            return $src;
        }
        
        // find function bodies, and inject new assert() lists
        $src = preg_replace_callback(self::RX_FUNC, [$this, "rx_func"], $src);
        return $src;
    }
    

    // Regexps
    const RX_ASSERT = "/
       ^\h* assert\(\' [^{'}]+ \/\*\h*type\h*hint\h*\*\/ \'\);\h*\R
    /mix";
    const RX_FUNC = "/
        (?<indent> \h*)
        (?:(?:public|private|protected|static)\s+)*
        function
        \s*
        (?<name> [\w\pL]+)
        \s*
        \( (?<params> [^{}]* ) \)
        (?<rettype> \s*:\s* \w+)?
        \s*
        \{ (\h*\R)?
    /mix";
    const RX_PARAM = "/
       (?:
          (?: \/\*\s*)?
          (?<type>\w+)
          (?<expr>(?:[,\s]*[\w()$]*[<>!=]\s*[-+\d.]+[\w()]*)*)
          (?: \s*\*\/\s*|\s+)
       )?
       (\s* \&)?
       \s* (?<name>[$][\w\pL]+)
       (?<def> \s*=\s* NULL)?
    /mix";


    // Append to function declaration headers
    public function rx_func (/*array*/ $match) {
    
        // whole function declaration header
        $decl = $match[0];
        $ws = preg_replace("/\S/", " ", $match["indent"]) . "    ";
        
        // list parameters
        if (preg_match_all(self::RX_PARAM, $match["params"], $params, PREG_SET_ORDER)) {
            foreach ($params as $p) {

                // recognized type prefix?
                if ($type = strtolower($p["type"]) and isset($this->types[$type])) {

                    // copy type assertion
                    $expr = $this->types[$type];

                    // add expression checks
                    if (!empty($p["expr"])) {
                        $expr = join(" && ", array_merge(
                            ["($expr)"],
                            array_map(
                                function($add_x) {
                                    return is_int(strpos($add_x, "$"))
                                         ? "($add_x)"
                                         : "(\$ {$add_x})";
                                },
                                array_filter(preg_split("/\s*,\s*/", $p["expr"]))
                            )
                        ));
                    }

                    // add null alternative
                    if (isset($p["def"]) && stristr($p["def"], "NULL")) {
                        $expr .= $this->types["=null"];
                    }
                    
                    // substitute param name and just append to function declaration header
                    $expr = preg_replace("/[\$](?!\w)/", $p["name"], $expr);
                    $decl .= "{$ws}assert('$expr /*typehint*/');\n";
                }
            }
        }
        
        return $decl;
    }

}



// Split files from -options
function argv(/*int*/ $x=NULL) {
    $argv = array_slice($_SERVER["argv"], 1);
    $opts = preg_grep("/^-+(rm?|de?l?)/i", $argv);
    return [array_diff($argv, $opts), $opts];
}


#include <ispect.ph>

