3
\$\begingroup\$

I've written a simple helper function for my helper class that allows me to check if a substring is in a string.

With my function, you can supply either a single needle to check for, or an array of needles to check for. There is also a parameter that allows you to select if you want to check word boundries or not, and a parameter to allow you to choose if the search should be case sensitive or not.

public function inString($needle, $haystack, $checkBoundries = false, $caseSensitive = false) {
        if(is_array($needle) && count($needle) < 2) 
        {
            $needle = $needle[0];
        }       
        if(is_array($needle)) 
        {
            $pattern = "(".implode("|", $needle).")";
            if($checkBoundries) {
                $pattern = "(\b{$pattern}\b)";
            }
            $pattern = $caseSensitive ?  "/{$pattern}/" : "/{$pattern}/i";
            return preg_match($pattern, $haystack);
        }
        $pattern = $needle;
        if($checkBoundries) {
            $pattern = "(\b{$pattern}\b)";
        }
        $pattern = $caseSensitive ?  "/{$pattern}/" : "/{$pattern}/i";
        return preg_match($pattern, $haystack);
    }

I would love some advice or pointers for my function. Thank you!

\$\endgroup\$
3
\$\begingroup\$

I like YourCommonSense's unconditional casting of $needle as an array.

bumperbox's advice on preg_quote() is good and necessary.

bumperbox's patterns will have the desired accuracy because word boundaries (spelled with an 'a') when used with alternatives (|) must wrap each alternative or the alternatives must be grouped. Otherwise, if you have a pattern like: /\bdog|cat|fish\b/ then doghouse, category, scat, and blowfish will be unintentionally matched. (Bad Pattern Demo) In this case, /\b(?:dog|cat|fish)\b/ takes fewer steps than /\bdog\b|\bcat\b|\bfish\b/.

Capture groups () will not be necessary for this task, so use a non-capturing group (?:). The fullstring match will contain the substring. Unnecessary capture groups bloat the output array (although only by one element in this case).

Different from the other answers, I have built in a shortcut to a non-regex method. The rationale behind this is the general knowledge that regex functions are notoriously/usually slower than non-regex functions. The cost of building the path to the non-regex processes is minimal -- just 3 conditions, only the 2nd condition uses a function call. I suspect any gains in performance to only manifest when dealing with rather large haystacks. Regardless, I think it is worth considering.

My function performs more checks than the others to prioritize building a lean, accurate pattern.

My function will return false or the matched needle string.

*For those who might not know the behavior of alternatives in regex, the match will be the alternative found with the lowest "offset". Regex will not prioritize matching by the order of the alternatives.

Code: (PHP Demo)

function inString($needle, $haystack, $checkBoundaries = false, $caseSensitive = false){
    $needle=(array)$needle;  // unconditionally cast $needle to be an array

    // conditional shortcut to non-regex method:
    if(!$checkBoundaries && sizeof($needle)==1){
        $needle=$needle[0];  // or current($needle) if not indexed
        if(!$caseSensitive){
            return stripos($haystack,$needle)!==false?$needle:false;
        }
        return strpos($haystack,$needle)!==false?$needle:false;
    }

    // build regex pattern
    foreach($needle as &$word){
        $word=preg_quote($word,'/');  // escape regex-sensitive characters
    }
    $pattern=implode('|',$needle);  // pipe together the alternatives
    if($checkBoundaries){
        if(sizeof($needle)>1){
            $pattern="(?:{$pattern})";  // non-capture group required
        }
        $pattern="\b{$pattern}\b";  // word boundaries required
    }
    $pattern="/{$pattern}/";  // add pattern delimiters
    if(!$caseSensitive){
        $pattern.='i';  // case-insensitive modifier required
    }
    // execute pattern and return string on true
    return preg_match($pattern, $haystack, $matches)?$matches[0]:false;
}

Alternatively, if you are seeking pattern accuracy with fewest function calls here is something new to consider:

Code #2: (Demo)

function inString($needle, $haystack, $checkBoundaries = false, $caseSensitive = false){
    $pattern='\Q'.implode('\E|\Q',(array)$needle).'\E';  // build "literal" alternatives
    if($checkBoundaries){
        $pattern="\b(?:{$pattern})\b";  // word boundaries required
    }
    $pattern="/{$pattern}/";  // wrap with pattern delimiters
    if(!$caseSensitive){
        $pattern.='i';  // case-insensitive modifier required
    }
    // execute pattern and return string on true
    return preg_match($pattern, $haystack, $matches)?$matches[0]:false;
}

Running conditional checks on $checkBoundaries and $caseSensitive is completely unavoidable for this task. This second method will make just one function call: implode() to complete the pattern!

  • By utilizing an "escape sequence" (\Q and \E), I was able to remove the foreach() loop and iterated call of preg_quote()! The addition of the escape sequence seems to have no impact on regex step count; it is just a different syntax. The effect on the pattern is in some cases a visual improvement, and in other cases a detriment to pattern readability (though I don't think pattern readability is a focus for this task). The above method wraps each alternative in \Q...\E whether it needs it or not. To contrast, preg_quote() will (only as necessary) add \ before each and every character in its list . \ + * ? [ ^ ] $ ( ) { } = ! < > | : - /. As I mentioned before either approach risks obfuscating the pattern.

  • In all fairness, this method builds a less lean pattern than my first method, but you can logically expect that it will construct pattern with the greatest efficiency and with minimal drag from preg_match().

\$\endgroup\$
6
  • \$\begingroup\$ I almost used array_map as well, but after doing some research on it vs a foreach loop, I found many sources that said it's half or almost half the speed of a foreach loop. Thank you for your answer, I will definitely use some of what you said! \$\endgroup\$ – GrumpyCrouton Oct 27 '17 at 12:41
  • \$\begingroup\$ I can't deny that logic. I've adjusted my answer to modify the needles by reference within a foreach loop . \$\endgroup\$ – mickmackusa Oct 27 '17 at 23:16
  • 1
    \$\begingroup\$ @GrumpyCrouton While on safari in the regex documentation today, I found something useful. Please read my new second method . \$\endgroup\$ – mickmackusa Oct 30 '17 at 7:33
  • \$\begingroup\$ That's awesome, I will consider implementing this change into the class! If you want, feel free to do a pull request on the github class, otherwise I can do it myself. \$\endgroup\$ – GrumpyCrouton Oct 30 '17 at 16:42
  • \$\begingroup\$ I'll leave you to handle your githubbing. Happy to help. \$\endgroup\$ – mickmackusa Oct 31 '17 at 3:10
2
\$\begingroup\$

Here you go, i have simplified your function a little, and applied some tests

<?php

$haystack = "hello world testing /#$";

echo "Haystack: '{$haystack}'<br><br>";

// simple test
$needles = 'Hello';
echo "Case Sensitive: ($needles)<br>";
echo "Ret: " . inString($needles, $haystack, false, true) ."<br><br>";

// check regex chars are being escaped
$needles = '/#';
echo "Escape Regex: ($needles)<br>";
echo "Ret: " . inString($needles, $haystack) ."<br><br>";

// check without boundary
$needles = 'rld';
echo "No boundary ($needles)<br>";
echo "Ret: " . inString($needles, $haystack) ."<br><br>";

// check with boundary
$needles = 'rld';
echo "Boundary ($needles)<br>";
echo "Ret: " . inString($needles, $haystack, true) ."<br><br>";

// check array
$needles = ['rld', 'world'];
echo "Array of Needles [".implode(',', $needles) . ']<br>';
echo "Ret: " . inString($needles, $haystack, true) ."<br><br>";

function inString($needles, $haystack, $checkBoundries = false, $caseSensitive = false) {

    // convert all needles to array
    if (!is_array($needles)) {
        $needles = [$needles];
    }

    // escape any regex chars
    foreach ($needles as &$needle) {
        $needle = preg_quote($needle, '/');
    }

    // assemble pattern
    $pattern = "(".implode("|", $needles).")";

    // apply boundaries to pattern, surrounding () not required
    if($checkBoundries) {
        $pattern = "\b{$pattern}\b";
    }

    // case modifier
    $modifiers = $caseSensitive ? '' : 'i';

    echo "Pattern: /$pattern/$modifiers<br>";

    $ret = preg_match("/$pattern/$modifiers", $haystack, $matches);

    // this is for debugging only not required
    if ($ret !== false) {
        array_shift($matches);
        foreach ($matches as $match) {
            echo "Match On: {$match}<br>";
        }
    } else {
        echo "No Match<br>";
    }

    return $ret;
}

This is the output i get

Haystack: 'hello world testing /#$'

Case Sensitive: (Hello)
Pattern: /(Hello)/
Ret: 0

Escape Regex: (/#)
Pattern: /(\/#)/i
Match On: /#
Ret: 1

No boundary (rld)
Pattern: /(rld)/i
Match On: rld
Ret: 1

Boundary (rld)
Pattern: /\b(rld)\b/i
Ret: 0

Array of Needles [rld,world]
Pattern: /\b(rld|world)\b/i
Match On: world
Ret: 1
\$\endgroup\$
2
  • \$\begingroup\$ So should i just preg_quote() the haystack? I have done testing of my function but the thought about issues with regex syntax didn't even cross my mind \$\endgroup\$ – GrumpyCrouton Oct 26 '17 at 16:09
  • \$\begingroup\$ No you want to preg_quote the pattern, I will chuck an example in \$\endgroup\$ – bumperbox Oct 26 '17 at 16:13
2
\$\begingroup\$

Every time you see a duplicated code you know it should be eliminated

public function inString($needle, $haystack, $checkBoundries = false, $caseSensitive = false) {
    $needle = (array)$needle;
    $pattern = "(".implode("|", $needle).")";
    if($checkBoundries) {
        $pattern = "(\b{$pattern}\b)";
    }
    $pattern = $caseSensitive ?  "/{$pattern}/" : "/{$pattern}/i";
    return preg_match($pattern, $haystack);
}
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.