-1

Say I have a function templateMap which, for each subarray of $array, replaces every occurrence of @n (for some n) in the given $string with values from that subarray, returning the new array of subarrays. Also say that I want to allow the user to backslash the @ symbol, (which means allowing \ to be backslashed too).

For example:

function templateMap ($string, $array) {
    $newArray = array();
    foreach($array as $subArray) {
        foreach($subArray as $replacements) {
            ...
        }
    }
    return $newArray;
}


// for grouping mysql statements with parentheses
templateMap("(@)", array(" col1 < 5 && col2 > 6 ", " col3 < 3 || col4 > 7"));

This would produce

array("( col1 < 5 && col2 > 6 )", "( col3 < 3 || col4 > 7 )")

Here's a more complex example with multiple arguments - probably not easy to implement

templateMap("You can tweet @0 \@2 @1", array(
    array("Sarah", "ssarahtweetzz"),
    array("John", "jjohnsthetweetiest"),
    ...
));

/* output: 
array(
    "You can tweet Sarah @2 ssarahtweetzz",
    "You can tweet John @2 jjohnsthetweetiest"
)
*/

Is there a way to accomplish this with a series of str_replace calls? (As opposed to with a regex or a simple state machine.)

One thing that I thought of was to replace the occurrences of \@ with some exotic string not found in the current string, such as zzzzzz, but of course, then you have to check to see if the string is in the given string, and modify it accordingly.

Chris Middleton
  • 5,654
  • 5
  • 31
  • 68

2 Answers2

1

When doing the replacements, there can't be any @s other than the ones that need replacing... so we have to get rid of all the \@ sequences. But when we get rid of all the \@ sequences, there can't be any \@ that are actually part of a \\@ (two backslashes followed by a @) sequence. To get rid of the \\ sequences, we can use a new escape character %.

Specifically, if we escape % as %%, then we can escape any other sequence as ?%? where ? is any character and be guaranteed that the ?%? can be de-escaped, since % will never appear alone in the middle.

// wrapper for native strings to make chaining easier
class String {
    private $str;
    public function __construct ($str) {
        $this->str = $str;
    }
    public function replace ($search, $substitute) {
        return new self(str_replace($search, $substitute, $this->str));
    }
    public function toRaw () {
        return $this->str;
    }
}

function templateMap ($str, $arr) {
    $encodedStr = (new String($str))->replace('%', '%%')
        ->replace('\\\\', '?%?')->replace('\@', '!%!');
    $newArr = array();
    foreach($arr as $el) {
        $encodedStrPieces = explode("@", $encodedStr->toRaw());
        foreach($encodedStrPieces as $i => $piece) {
            $encodedStrPieces[$i] = (new String($piece))->replace("@", $el)
            ->replace('!%!', '@')->replace('?%?', '\\')
            ->replace('%%', '%')->toRaw();
        }
        $newArr[] = implode($el, $encodedStrPieces);
    }
    return $newArr;
}


$arr = templateMap("(@\@)", array("hello", "goodbye"));
var_dump($arr); // => ["(hello@)", "(goodbye@)"]
Chris Middleton
  • 5,654
  • 5
  • 31
  • 68
  • I don't know is this an issue for the OP, but this solution would not preserve double percent signs. For `$arr = templateMap("(@\@)", array("hel%%o"));` it will produce `(hel%o@)` instead of `(hel%%o@)` – mhall Mar 19 '15 at 07:57
  • @mhall, 1) thanks for your contribution above, I'll be interested to read it, 2) (I'm the OP), 3) good catch - I've modified the code by splitting on the "@" and then decoding before substituting. – Chris Middleton Mar 19 '15 at 15:32
1

I think the major problem when limited to only use str_replace is that you have little control over which strings have been replaced (as all occurrences are replaced at once) and you need to take special care when choosing a placeholder for the \@ escape sequence. There is the possibility that two inserted values combined would produce the placeholder string and therefore be turned into an @ char when the placeholder replacement is reverted.

The below is a brute force kind of solution that tries to handle that. It checks one placeholder at a time against the template string, the replacement values and the final string, making sure that the placeholder does not appear within any of these strings and that the number of placeholders originally introduced for \@ matches the number of placeholders reverted. You probably would like to set a default placeholder instead of xyz (like zero char or something) that works best for you, to avoid unnecessary processing.

It can be called with both kinds of substitution patterns (@ and @<n>) but currently they cannot be mixed.

It is not the prettiest code I have ever written, but given the str_replace constraint it is nevertheless my shot at it and I hope it can be of some help to you.

function templateMap ($string, $array, $defaultPlaceholder = "xyz")
{
    $newArray = array();

    // Create an array of the subject string and replacement arrays
    $knownStrings = array($string);
    foreach ($array as $subArray) {
        if (is_array($subArray)) {
            $knownStrings = array_merge($knownStrings, array_values($subArray));
        }
        else {
            $knownStrings[] = $subArray;
        }
    }

    $placeHolder = '';

    while (true) {
        if (!$placeHolder) {
            // This is the first try, so let's try the default placeholder
            $placeHolder = $defaultPlaceholder;
        }
        else {
            // We've been here before - we need to try another placeholder
            $placeHolder = uniqid('bs-placeholder-', true);
        }

        // Try to find a placeholder that does not appear in any of the strings
        foreach ($knownStrings as $knownString) {
            // Does $placeHolder exist in $knownString?
            str_replace($placeHolder, 'whatever', $knownString, $count);
            if ($count > 0) {
                // Placeholder candidate was found in one of the strings
                continue 2; // Start over
            }
        }

        // Will go for placeholder "$placeHolder"
        foreach ($array as $subArray) {
            $newString = $string;

            // Apply placeholder for \@ - remember number of replacements
            $newString = str_replace(
                '\@', $placeHolder, $newString, $numberOfFirstReplacements
            );

            if (is_array($subArray)) {
                // Make substitution on @<n>
                for ($i = 0; $i <= 9; $i++) {
                    @$newString = str_replace("@$i", $subArray[$i], $newString);
                }
            }
            else {
                // Make substitution on @
                @$newString = str_replace("@", $subArray, $newString);
            }

            // Revert placeholder for \@ - remember number of replacements
            $newString = str_replace(
                $placeHolder, '@', $newString, $numberOfSecondReplacements
            );

            if ($numberOfFirstReplacements != $numberOfSecondReplacements) {
                // Darn - value substitution caused used placeholder to appear,
                // ruining our day - we need some other placeholder
                $newArray = array();
                continue 2;
            }

            // Looks promising
            $newArray[] = $newString;
        }

        // All is well that ends well
        break;
    }
    return $newArray;
}

$a = templateMap(
    "(@ and one escaped \@)",
    array(" col1 < 5 && col2 > 6", " col3 < 3 || col4 > 7")
);
print_r($a);

$a = templateMap(
    "You can tweet @0 \@2 @1",
    array(
        array("Sarah", "ssarahtweetz"),
        array("John", "jjohnsthetweetiest"),
    )
);
print_r($a);

Output:

Array
(
    [0] => ( col1 < 5 && col2 > 6 and one escaped @)
    [1] => ( col3 < 3 || col4 > 7 and one escaped @)
)
Array
(
    [0] => You can tweet Sarah @2 ssarahtweetz
    [1] => You can tweet John @2 jjohnsthetweetiest
)
mhall
  • 3,671
  • 3
  • 23
  • 35