在PHP中合并正则表达式

10

假设我有以下两个包含正则表达式的字符串,如何将它们合并?更具体地说,我想把这两个表达式作为可选项。

Suppose I have the following two strings containing regular expressions. How do I coalesce them? More specifically, I want to have the two expressions as alternatives.

$a = '# /[a-z] #i';
$b = '/ Moo /x';
$c = preg_magic_coalesce('|', $a, $b);
// Desired result should be equivalent to:
// '/ \/[a-zA-Z] |Moo/'
当然,将此操作作为字符串操作并不实际,因为它将涉及解析表达式、构造语法树、合并树,然后输出另一个等效于该树的正则表达式。我非常满意没有这最后一步。不幸的是,PHP没有RegExp类(或者有吗?)。
是否有任何方法可以实现这一点?顺便问一下,其他语言提供了方法吗?这不是很常见的情况吗?猜想不是。 :-( 或者,是否有一种有效的方法来检查这两个表达式是否匹配,并确定哪一个更早地匹配(如果它们在同一位置匹配,哪一个匹配更长)?这就是我目前正在做的。不幸的是,我经常在长字符串上执行此操作,对于超过两个模式。结果很慢(是的,这肯定是瓶颈)。
编辑: 我应该更明确-对不起。$a和$b是变量,它们的内容不受我控制!否则,我将手动合并它们。因此,我不能对使用的定界符或正则表达式修饰符做出任何假设。请注意,例如,我的第一个表达式使用i(忽略大小写)修改器,而第二个使用x(扩展语法)。因此,我不能简单地连接这两个表达式,因为第二个表达式不忽略大小写,而第一个表达式没有使用扩展语法(其中的任何空格都很重要!)。
注:原文中有HTML标签,已保留。
6个回答

3
  1. Strip delimiters and flags from each. This regex should do it:

    /^(.)(.*)\1([imsxeADSUXJu]*)$/
    
  2. Join expressions together. You'll need non-capturing parenthesis to inject flags:

    "(?$flags1:$regexp1)|(?$flags2:$regexp2)"
    
  3. If there are any back references, count capturing parenthesis and update back references accordingly (e.g. properly joined /(.)x\1/ and /(.)y\1/ is /(.)x\1|(.)y\2/ ).


3

我看到porneL实际上描述了其中的一些问题,但这个处理大部分问题。它取消了先前子表达式中设置的修饰符(另一个答案遗漏了这一点),并根据每个子表达式指定的修饰符设置修饰符。它还处理了非斜杠分隔符(我找不到允许使用哪些字符的规范,所以我使用了 . ,您可能需要进一步缩小范围)。

一个弱点是它不能处理表达式内的反向引用。我最担心的是反向引用本身的限制。我将其留给读者/提问者作为练习。

// Pass as many expressions as you'd like
function preg_magic_coalesce() {
    $active_modifiers = array();

    $expression = '/(?:';
    $sub_expressions = array();
    foreach(func_get_args() as $arg) {
        // Determine modifiers from sub-expression
        if(preg_match('/^(.)(.*)\1([eimsuxADJSUX]+)$/', $arg, $matches)) {
            $modifiers = preg_split('//', $matches[3]);
            if($modifiers[0] == '') {
                array_shift($modifiers);
            }
            if($modifiers[(count($modifiers) - 1)] == '') {
                array_pop($modifiers);
            }

            $cancel_modifiers = $active_modifiers;
            foreach($cancel_modifiers as $key => $modifier) {
                if(in_array($modifier, $modifiers)) {
                    unset($cancel_modifiers[$key]);
                }
            }
            $active_modifiers = $modifiers;
        } elseif(preg_match('/(.)(.*)\1$/', $arg)) {
            $cancel_modifiers = $active_modifiers;
            $active_modifiers = array();
        }

        // If expression has modifiers, include them in sub-expression
        $sub_modifier = '(?';
        $sub_modifier .= implode('', $active_modifiers);

        // Cancel modifiers from preceding sub-expression
        if(count($cancel_modifiers) > 0) {
            $sub_modifier .= '-' . implode('-', $cancel_modifiers);
        }

        $sub_modifier .= ')';

        $sub_expression = preg_replace('/^(.)(.*)\1[eimsuxADJSUX]*$/', $sub_modifier . '$2', $arg);

        // Properly escape slashes
        $sub_expression = preg_replace('/(?<!\\\)\//', '\\\/', $sub_expression);

        $sub_expressions[] = $sub_expression;
    }

    // Join expressions
    $expression .= implode('|', $sub_expressions);

    $expression .= ')/';
    return $expression;
}

编辑:我已经重新撰写了这篇文章(因为我有强迫症),最终得到:

function preg_magic_coalesce($expressions = array(), $global_modifier = '') {
    if(!preg_match('/^((?:-?[eimsuxADJSUX])+)$/', $global_modifier)) {
        $global_modifier = '';
    }

    $expression = '/(?:';
    $sub_expressions = array();
    foreach($expressions as $sub_expression) {
        $active_modifiers = array();
        // Determine modifiers from sub-expression
        if(preg_match('/^(.)(.*)\1((?:-?[eimsuxADJSUX])+)$/', $sub_expression, $matches)) {
            $active_modifiers = preg_split('/(-?[eimsuxADJSUX])/',
                $matches[3], -1, PREG_SPLIT_NO_EMPTY|PREG_SPLIT_DELIM_CAPTURE);
        }

        // If expression has modifiers, include them in sub-expression
        if(count($active_modifiers) > 0) {
            $replacement = '(?';
            $replacement .= implode('', $active_modifiers);
            $replacement .= ':$2)';
        } else {
            $replacement = '$2';
        }

        $sub_expression = preg_replace('/^(.)(.*)\1(?:(?:-?[eimsuxADJSUX])*)$/',
            $replacement, $sub_expression);

        // Properly escape slashes if another delimiter was used
        $sub_expression = preg_replace('/(?<!\\\)\//', '\\\/', $sub_expression);

        $sub_expressions[] = $sub_expression;
    }

    // Join expressions
    $expression .= implode('|', $sub_expressions);

    $expression .= ')/' . $global_modifier;
    return $expression;
}

现在它使用(?modifiers:sub-expression)而不是(?modifiers)sub-expression|(?cancel-modifiers)sub-expression,但我注意到两者都有一些奇怪的修饰符副作用。例如,在两种情况下,如果子表达式具有/u修饰符,则无法匹配(但如果将'u'作为新函数的第二个参数传递,那么匹配就可以了)。

3

编辑

我已经重新编写了代码! 现在它包含以下列出的更改。此外,我进行了广泛的测试(因为它们太多而不会在此发布)以查找错误。到目前为止,我还没有发现任何错误。

现在这个函数已经分成了两部分:有一个独立的函数 preg_split,它接受一个正则表达式并返回一个包含裸表达式(不带分隔符)和修饰符数组的数组。这可能会很方便(实际上已经很方便了;这就是我做出这个改变的原因)。
代码现在正确处理反向引用。这对我的目的来说是必要的。添加它并不困难,用于捕获反向引用的正则表达式看起来很奇怪(实际上可能非常低效,对我来说看起来像 NP-hard——但这只是一种直觉,并且仅适用于奇怪的边缘情况)。顺便问一下,有没有人知道比我的方法更好的检查匹配数量是否为奇数的方法?否定回溯在这里行不通,因为它们只接受固定长度的字符串而不是正则表达式。但是,在这里我需要使用正则表达式来测试前面的反斜杠是否被转义。
此外,我不知道 PHP 在缓存匿名 create_function 使用方面有多好。从性能上讲,这可能不是最佳解决方案,但它似乎足够好。
我已经修复了一个健全性检查中的错误。
我已经删除了过时修饰符的取消,因为我的测试表明这是不必要的。

顺便说一下,这段代码是我正在PHP中为各种语言编写的语法突出显示器的核心组件之一,因为我对其他地方列出的替代方案不满意。

谢谢!

porneLeyelidlessness,做得很好!非常感谢。 我实际上已经放弃了。

我在您的解决方案基础上进行了改进,并想在此分享。 我没有实现重新编号后向引用,因为在我的情况下这并不相关(我认为…)。 也许以后会变得必要。

一些问题...

有一件事,@eyelidlessness为什么您觉得有必要取消旧的修饰符?就我所看到的,这并不必要,因为修饰符只是局部应用的。 啊是的,还有一件事。 您转义分隔符的方式似乎过于复杂。 能否解释一下为什么您认为这是必要的? 我相信我的版本也应该可以工作,但我可能非常错误。

另外,我已经更改了您的函数签名以适应我的需求。我认为我的版本更加通用。当然,我可能是错的。

顺便说一句,现在你应该意识到在SO上使用真实姓名的重要性了。;-)我无法在代码中给你真正的信用。:-/

代码

无论如何,我想分享我的目前结果,因为我不敢相信没有人需要这样的东西。代码似乎工作得非常好。 尽管还需要进行广泛的测试。请评论!

没有更多的拖延了...

/**
 * Merges several regular expressions into one, using the indicated 'glue'.
 *
 * This function takes care of individual modifiers so it's safe to use
 * <em>different</em> modifiers on the individual expressions. The order of
 * sub-matches is preserved as well. Numbered back-references are adapted to
 * the new overall sub-match count. This means that it's safe to use numbered
 * back-refences in the individual expressions!
 * If {@link $names} is given, the individual expressions are captured in
 * named sub-matches using the contents of that array as names.
 * Matching pair-delimiters (e.g. <code>"{…}"</code>) are currently
 * <strong>not</strong> supported.
 *
 * The function assumes that all regular expressions are well-formed.
 * Behaviour is undefined if they aren't.
 *
 * This function was created after a {@link https://dev59.com/DnVC5IYBdhLWcg3wlyQo
 * StackOverflow discussion}. Much of it was written or thought of by
 * “porneL” and “eyelidlessness”. Many thanks to both of them.
 *
 * @param string $glue  A string to insert between the individual expressions.
 *      This should usually be either the empty string, indicating
 *      concatenation, or the pipe (<code>|</code>), indicating alternation.
 *      Notice that this string might have to be escaped since it is treated
 *      like a normal character in a regular expression (i.e. <code>/</code>)
 *      will end the expression and result in an invalid output.
 * @param array $expressions    The expressions to merge. The expressions may
 *      have arbitrary different delimiters and modifiers.
 * @param array $names  Optional. This is either an empty array or an array of
 *      strings of the same length as {@link $expressions}. In that case,
 *      the strings of this array are used to create named sub-matches for the
 *      expressions.
 * @return string An string representing a regular expression equivalent to the
 *      merged expressions. Returns <code>FALSE</code> if an error occurred.
 */
function preg_merge($glue, array $expressions, array $names = array()) {
    // … then, a miracle occurs.

    // Sanity check …

    $use_names = ($names !== null and count($names) !== 0);

    if (
        $use_names and count($names) !== count($expressions) or
        !is_string($glue)
    )
        return false;

    $result = array();
    // For keeping track of the names for sub-matches.
    $names_count = 0;
    // For keeping track of *all* captures to re-adjust backreferences.
    $capture_count = 0;

    foreach ($expressions as $expression) {
        if ($use_names)
            $name = str_replace(' ', '_', $names[$names_count++]);

        // Get delimiters and modifiers:

        $stripped = preg_strip($expression);

        if ($stripped === false)
            return false;

        list($sub_expr, $modifiers) = $stripped;

        // Re-adjust backreferences:

        // We assume that the expression is correct and therefore don't check
        // for matching parentheses.

        $number_of_captures = preg_match_all('/\([^?]|\(\?[^:]/', $sub_expr, $_);

        if ($number_of_captures === false)
            return false;

        if ($number_of_captures > 0) {
            // NB: This looks NP-hard. Consider replacing.
            $backref_expr = '/
                (                # Only match when not escaped:
                    [^\\\\]      # guarantee an even number of backslashes
                    (\\\\*?)\\2  # (twice n, preceded by something else).
                )
                \\\\ (\d)        # Backslash followed by a digit.
            /x';
            $sub_expr = preg_replace_callback(
                $backref_expr,
                create_function(
                    '$m',
                    'return $m[1] . "\\\\" . ((int)$m[3] + ' . $capture_count . ');'
                ),
                $sub_expr
            );
            $capture_count += $number_of_captures;
        }

        // Last, construct the new sub-match:

        $modifiers = implode('', $modifiers);
        $sub_modifiers = "(?$modifiers)";
        if ($sub_modifiers === '(?)')
            $sub_modifiers = '';

        $sub_name = $use_names ? "?<$name>" : '?:';
        $new_expr = "($sub_name$sub_modifiers$sub_expr)";
        $result[] = $new_expr;
    }

    return '/' . implode($glue, $result) . '/';
}

/**
 * Strips a regular expression string off its delimiters and modifiers.
 * Additionally, normalize the delimiters (i.e. reformat the pattern so that
 * it could have used '/' as delimiter).
 *
 * @param string $expression The regular expression string to strip.
 * @return array An array whose first entry is the expression itself, the
 *      second an array of delimiters. If the argument is not a valid regular
 *      expression, returns <code>FALSE</code>.
 *
 */
function preg_strip($expression) {
    if (preg_match('/^(.)(.*)\\1([imsxeADSUXJu]*)$/s', $expression, $matches) !== 1)
        return false;

    $delim = $matches[1];
    $sub_expr = $matches[2];
    if ($delim !== '/') {
        // Replace occurrences by the escaped delimiter by its unescaped
        // version and escape new delimiter.
        $sub_expr = str_replace("\\$delim", $delim, $sub_expr);
        $sub_expr = str_replace('/', '\\/', $sub_expr);
    }
    $modifiers = $matches[3] === '' ? array() : str_split(trim($matches[3]));

    return array($sub_expr, $modifiers);
}

PS:我已将此帖子设置为维基社区可编辑。你知道这意味着什么……!


嘿,我出名了! ;) 看看我的编辑,我认为它解决了修改器/取消修改器的问题;如果原始内容实际上不是斜杠,我只会在子表达式中转义斜杠,我看你在你的代码中更清楚地表达了这一点。 - eyelidlessness

1

我相信在任何语言中都不可能只是简单地将正则表达式放在一起 - 它们可能具有不兼容的修饰符。

我可能会将它们放入数组中并循环遍历它们,或手动组合它们。

编辑:如果您按照您的编辑描述逐个执行它们,则可以尝试在子字符串上运行第二个正则表达式(从开头到最早匹配)。这可能有所帮助。


不幸的是,仅在子字符串上连续运行表达式并不起作用,因为它们可能会在另一个表达式之前开始,但在其之后结束。如果我只测试前缀,这种情况就无法被捕获。 - Konrad Rudolph

0
function preg_magic_coalasce($split, $re1, $re2) {
  $re1 = rtrim($re1, "\/#is");
  $re2 = ltrim($re2, "\/#");
  return $re1.$split.$re2;
}

不幸的是,这样做不起作用 - 通过移除is修改器,您已更改了$re1的含义。 - Greg

0
你也可以像这样选择另一种方法:
$a = '# /[a-z] #i';
$b = '/ Moo /x';

$a_matched = preg_match($a, $text, $a_matches);
$b_matched = preg_match($b, $text, $b_matches);

if ($a_matched && $b_matched) {
    $a_pos = strpos($text, $a_matches[1]);
    $b_pos = strpos($text, $b_matches[1]);

    if ($a_pos == $b_pos) {
        if (strlen($a_matches[1]) == strlen($b_matches[1])) {
            // $a and $b matched the exact same string
        } else if (strlen($a_matches[1]) > strlen($b_matches[1])) {
            // $a and $b started matching at the same spot but $a is longer
        } else {
            // $a and $b started matching at the same spot but $b is longer
        }
    } else if ($a_pos < $b_pos) {
        // $a matched first
    } else {
        // $b matched first
    }
} else if ($a_matched) {
    // $a matched, $b didn't
} else if ($b_matched) {
    // $b matched, $a didn't
} else {
    // neither one matched
}

Jeremy,感谢您的建议,但preg_match有一个标志(PREG_OFFSET_CAPTURE),可以记录匹配位置。所以这不是问题。它只是非常低效(尽管仍比您的代码高效得多)。 - Konrad Rudolph

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接