views:

99

answers:

3

Hello there,

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!

A: 

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);
}
Josiah
A: 

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];
        }
    }
}
Javaguru
Thanks, Javaguru! Your response looks great! However, is there any way to avoid adding the extra quotes inside strings (as in '\'This is a string\'')?
Yes. I think this should be feasible. But you have to be aware whether the expanded string will be used within an eval-expression (-> requires quotes) or is just replaced. With other words: The callback for preg_replace_callback must add quotes to the returned value if $str begins with ^, and if $vars[$matches[1]] is not numeric (-> is_numeric($vars[$matches[1]])).
Javaguru
Hmmm, would it be possible for you to show me that in the code? That's the part in which I messed up with my original implementation... :( Also, do you think that would work in situations where strings include variables containing other strings/evals?Thanks super much!
BTW, I'm not sure I'd check for is_numeric... If a variable is set to '0123', I need to preserve the '0' in the string (just like in Zip codes, where 02478 is different from 2478...)
Thanks once again, Javaguru! This is awesome!
A: 

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?)

nikic