PHP递归变量替换

I'm writing code to recursively replace predefined variables from inside a given string. The variables are prefixed with the character '%'. Input strings that start with '^' are to be evaluated.

For instance, assuming an array of variables such as:

$vars['a'] = 'This is a string';  
$vars['b'] = '123';  
$vars['d'] = '%c';  // Note that $vars['c'] has not been defined
$vars['e'] = '^5 + %d';  
$vars['f'] = '^11 + %e + %b*2';  
$vars['g'] = '^date(\'l\')';  
$vars['h'] = 'Today is %g.';  
$vars['input_digits'] = '*****';  
$vars['code'] = '%input_digits';  

The following code would result in:

a) $str = '^1 + %c';  
   $rc = _expand_variables($str, $vars);  
  // Result: $rc == 1 

b) $str = '^%a != NULL';  
   $rc = _expand_variables($str, $vars);  
   // Result: $rc == 1  

c) $str = '^3+%f + 3';  
   $rc = _expand_variables($str, $vars);  
   // Result: $rc == 262  

d) $str = '%h';  
   $rc = _expand_variables($str, $vars);  
   // Result: $rc == 'Today is Monday'  

e) $str = 'Your code is: %code';  
   $rc = _expand_variables($str, $vars);  
   // Result:  $rc == 'Your code is: *****'  

Any suggestions on how to do that? I've spent many days trying to do this, but only achieved partial success. Unfortunately, my last attempt managed to generate a 'segmentation fault'!!

Help would be much appreciated!

Note that there is no check against circular inclusion, which would simply lead to an infinite loop. (Example: $vars['s'] = '%s'; ..) So make sure your data is free of such constructs. The commented code

    // if(!is_numeric($expanded) || (substr($expanded.'',0,1)==='0'
    //            && strpos($expanded.'', '.')===false)) {
..
    // }

can be used or skipped. If it is skipped, any replacement is quoted, if the string $str will be evaluated later on! But since PHP automatically converts strings to numbers (or should I say it tries to do so??) skipping the code should not lead to any problems. Note that boolean values are not supported! (Also there is no automatic conversion done by PHP, that converts strings like 'true' or 'false' to the appropriate boolean values!)

    <?
    $vars['a'] = 'This is a string';
    $vars['b'] = '123';
    $vars['d'] = '%c';
    $vars['e'] = '^5 + %d';
    $vars['f'] = '^11 + %e + %b*2';
    $vars['g'] = '^date(\'l\')';
    $vars['h'] = 'Today is %g.';
    $vars['i'] = 'Zip: %j';
    $vars['j'] = '01234';
    $vars['input_digits'] = '*****';
    $vars['code'] = '%input_digits';

    function expand($str, $vars) {
        $regex = '/\%(\w+)/';
        $eval = substr($str, 0, 1) == '^';
        $res = preg_replace_callback($regex, function($matches) use ($eval, $vars) {
            if(isset($vars[$matches[1]])) {
                $expanded = expand($vars[$matches[1]], $vars);
                if($eval) {
                    // Special handling since $str is going to be evaluated ..
//                    if(!is_numeric($expanded) || (substr($expanded.'',0,1)==='0'
//                            && strpos($expanded.'', '.')===false)) {
                        $expanded = "'$expanded'";
//                    }
                }
                return $expanded;
            } else {
                // Variable does not exist in $vars array
                if($eval) {
                    return 'null';
                }
                return $matches[0];
            }
        }, $str);
        if($eval) {
            ob_start();
            $expr = substr($res, 1);
            if(eval('$res = ' . $expr . ';')===false) {
                ob_end_clean();
                die('Not a correct PHP-Expression: '.$expr);
            }
            ob_end_clean();
        }
        return $res;
    }

    echo expand('^1 + %c',$vars);
    echo '<br/>';
    echo expand('^%a != NULL',$vars);
    echo '<br/>';
    echo expand('^3+%f + 3',$vars);
    echo '<br/>';
    echo expand('%h',$vars);
    echo '<br/>';
    echo expand('Your code is: %code',$vars);
    echo '<br/>';
    echo expand('Some Info: %i',$vars);
    ?>

The above code assumes PHP 5.3 since it uses a closure.

Output:

1
1
268
Today is Tuesday.
Your code is: *****
Some Info: Zip: 01234

For PHP < 5.3 the following adapted code can be used:

function expand2($str, $vars) {
    $regex = '/\%(\w+)/';
    $eval = substr($str, 0, 1) == '^';
    $res = preg_replace_callback($regex, array(new Helper($vars, $eval),'callback'), $str);
    if($eval) {
        ob_start();
        $expr = substr($res, 1);
        if(eval('$res = ' . $expr . ';')===false) {
            ob_end_clean();
            die('Not a correct PHP-Expression: '.$expr);
        }
        ob_end_clean();
    }
    return $res;
}

class Helper {
    var $vars;
    var $eval;

    function Helper($vars,$eval) {
        $this->vars = $vars;
        $this->eval = $eval;
    }

    function callback($matches) {
        if(isset($this->vars[$matches[1]])) {
            $expanded = expand($this->vars[$matches[1]], $this->vars);
            if($this->eval) {
                // Special handling since $str is going to be evaluated ..
                if(!is_numeric($expanded) || (substr($expanded . '', 0, 1)==='0'
                        && strpos($expanded . '', '.')===false)) {
                    $expanded = "'$expanded'";
                }
            }
            return $expanded;
        } else {
            // Variable does not exist in $vars array
            if($this->eval) {
                return 'null';
            }
            return $matches[0];
        }
    }
}

I actually just did this while implementing a MVC framework.

What I did was create a "find-tags" function that uses a regular expression to find all things that should be replaced using preg_match_all and then iterated through the list and called the function recursively with the str_replaced code.

VERY Simplified Code

function findTags($body)
{
    $tagPattern = '/{%(?P<tag>\w+) *(?P<inputs>.*?)%}/'

    preg_match_all($tagPattern,$body,$results,PREG_SET_ORDER);
    foreach($results as $command)
    {

        $toReturn[] = array(0=>$command[0],'tag'=>$command['tag'],'inputs'=>$command['inputs']);
    }
    if(!isset($toReturn))
        $toReturn = array();
    return $toReturn;
}

function renderToView($body)
{
    $arr = findTags($body);
    if(count($arr) == 0)
        return $body;
    else
    {
        foreach($arr as $tag)
        {
           $body = str_replace($tag[0],$LOOKUPARRY[$tag['tag']],$body);
        }

    }       
    return renderToView($body);
}

I now have written an evaluator for your code, which addresses the circular reference problem, too.

Use:

$expression = new Evaluator($vars);

$vars['a'] = 'This is a string';  
// ...

$vars['circular'] = '%ralucric';
$vars['ralucric'] = '%circular';

echo $expression->evaluate('%circular');

I use a $this->stack to handle circular references. (No idea what a stack actually is, I simply named it so ^^)

class Evaluator {
    private $vars;
    private $stack = array();
    private $inEval = false;

    public function __construct(&$vars) {
        $this->vars =& $vars;
    }

    public function evaluate($str) {
        // empty string
        if (!isset($str[0])) {
            return '';
        }

        if ($str[0] == '^') {
            $this->inEval = true;
            ob_start();
            eval('$str = ' . preg_replace_callback('#%(\w+)#', array($this, '_replace'), substr($str, 1)) . ';');
            if ($error = ob_get_clean()) {
                throw new LogicException('Eval code failed: '.$error);
            }
            $this->inEval = false;
        }
        else {
            $str = preg_replace_callback('#%(\w+)#', array($this, '_replace'), $str);
        }

        return $str;
    }

    private function _replace(&$matches) {
        if (!isset($this->vars[$matches[1]])) {
            return $this->inEval ? 'null' : '';
        }

        if (isset($this->stack[$matches[1]])) {
            throw new LogicException('Circular Reference detected!');
        }
        $this->stack[$matches[1]] = true;
        $return = $this->evaluate($this->vars[$matches[1]]);
        unset($this->stack[$matches[1]]);
        return $this->inEval == false ? $return : '\'' . $return . '\'';
    }
}

Edit 1: I tested the maximum recursion depth for this script using this:

$alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEF'; // GHIJKLMNOPQRSTUVWXYZ
$length = strlen($alphabet);
$vars['a'] = 'Hallo World!';
for ($i = 1; $i < $length; ++$i) {
    $vars[$alphabet[$i]] = '%' . $alphabet[$i-1];
}
var_dump($vars);
$expression = new Evaluator($vars);
echo $expression->evaluate('%' . $alphabet[$length - 1]);

If another character is added to $alphabet maximum recursion depth of 100 is reached. (But probably you can modify this setting somewhere?)