防止向前/向后查找匹配重叠

4
我试图匹配被引号包含的字符串字面量中间的所有部分。
下面是一个有效的正则表达式,它可以实现这个目标,除了一个例外:它当然会匹配所有被引号左右包围的字符串字面量的部分,而不管引号对是否成对。
例如(星号表示匹配的字符):
Hello "my" name is "Andy", nice to meet you.`
       ** ********* ****

这里的字符串文字部分 " name is " 之所以匹配,仅仅是因为它两侧有引号字符。这对于我们想要的结果是不正确的。理想的结果应该是:

Hello "my" name is "Andy", nice to meet you.`
       **           ****

充分理解这可以通过编写状态引擎来实现,我的问题是 - 在正则表达式中 - 如果可能的话,如何防止后顾匹配先前由前瞻匹配的字符串文字部分?


"((?>\"|.)*?)"不可行,因为它未匹配引号之间的内容。所以必须使用前后查找来确定引号的存在,但不匹配它们。 - Kana Ki
根据您使用的编程语言,您将能够将其转换为平衡括号问题(在本例中为平衡引号)。那么您使用的是哪种语言? - ndnenkov
可能可以做到你想要的,但是在我看来,最好先捕获引号然后再去除它们。 - user4003407
@AndyJames 我更新了答案,但只适用于Java。 - m.cekiera
@AndyJames 只是提供信息,我偶然发现我的正则表达式也适用于.NET。 - m.cekiera
显示剩余3条评论
4个回答

5

前言

由于您表示没有偏好,这更多是个人兴趣而不是实际的生产产品,因此我使用了Ruby。然而,请注意,尽管这里使用的一些技巧可能在各种正则表达式引擎(如javascript所拥有的引擎)上无法工作,或者对于相同的事情具有不同的语法,但这里没有使用任何特定于Ruby的内容。相同的正则表达式将在Perl、Sublime Text和许多其他地方起作用。


但在我们开始之前...

免责声明:这不是正确的做法!请勿在生产代码库中使用此方法!


既然我们已经讲清楚了... 这是一个非常有趣的问题。像任何其他复杂问题一样,分而治之 是解决问题的方式。

我们要使用的技巧:

  1. 命名组

就像您可以使用 (group_contents) 创建带编号的组一样,您也可以使用 (?<group_name>group_contents) 定义命名组。我们在技巧上并不需要这个,但它会使所有内容更加易于理解。

  1. 重新执行群组模式

您可以使用 \g<group_name_or_number> 来执行之前定义的相同模式。例如:

(?<three_letter_word>\b\w{3}\b) \g<three_letter_word>

将匹配xyz abc

  1. 重复零次

乍一看,{0}可能看起来没用。然而,与上述两个结合使用,它可以像定义函数但不执行它们一样工作。例如:

(?<even>[02468]){0}7\g<even>8\<even>9\<even>0

将匹配7x8y9z0,其中xyz是偶数位数字。

  1. 删除匹配的字符

许多正则表达式引擎中常见的限制是无法定义具有可变长度的后顾断言。即使在一些允许定义的引擎中(如Java),仍然需要定义最大长度。因此,您不能执行诸如(?<=x*)之类的操作。

\K来解救。基本上,\K的含义是“放弃到目前为止已匹配的所有内容”。因此,换句话说,(?<=x*)y可以重写为x*\Ky

掌握了这些技巧,我们开始吧。


首先,让我们使用技巧#3定义几个“函数”。

  1. escaped_quote
一个转义的引号是一个被一个奇数反斜杠(\)所跟随的引号(")。反斜杠有特殊的转义字符含义,所以为了匹配一个单独的反斜杠,我们需要用另一个反斜杠进行转义(即\\=一个字面上的反斜杠)。
为了匹配偶数反斜杠,我们可以使用\\{2}*(即两个反斜杠零次或多次-2*n)。为了使其变成奇数,我们只需要再添加一个反斜杠-\\\\{2}*(2*n + 1)。
我们还需要指出,我们希望匹配此序列中的所有反斜杠。这是因为正则表达式引擎会尝试找到一个偶数个反斜杠来欺骗我们,除非我们告诉它不这样做。 \\\\\"将被解释为非转义引号,因为它只能匹配\ \",忽略第一个斜杠。为了不允许这种情况,我们将添加一个负回溯,如下所示:(?

我们的escaped_quote"函数"的最终定义如下:

(?<escaped_quote>(?<!\\)\\\\{2}*"){0}
  1. non_quoting

我们要表达的另一件事是没有有意义引号的东西。这是一系列字符,它们是转义引号或根本不是引号。

请注意,对于根本不是引号的部分,我们需要添加一个负向先行断言来排除escaped_quote。这是为了确保我们不会吃掉escaped_quote中的第一个\,这将使我们剩下一个未转义的引号。

(?<non_quoting>(?:\g<escaped_quote>|(?!\g<escaped_quote>)[^"])*){0}
  1. 平衡引号

我们需要的最后一个“函数”是一个没有不匹配引号的序列。这可以是完全没有有意义的引号或者有偶数个有意义的引号:

(?<balanced_quotes>\g<non_quoting>|(?:\g<non_quoting>"\g<non_quoting>){2}+){0}


准备工作完成后,我们已经可以进行匹配了。

我们将从字符串的开头或单引号开始。前者是显而易见的。后者是因为我们的匹配会留下一个引号。 (?:^|")

编辑:结果证明这还不够。如果上次我们匹配了空字符串\K将不允许我们保持在相同的位置并在即席回顾中再次匹配空字符串。为了解决这个问题,我们将添加另一个选择 - 空字符串。请注意,这里的顺序很重要,以便只有其他两个失败时才使用此选择:(?:^|"|)

然后是一个non_quoting序列,使用#4技巧删除所有内容以实现后顾:

(?:^|"|)\g<non_quoting>"\K

接着,我们实际匹配的是一个非引用序列:

(?:^|"|)\g<non_quoting>"\K\g<non_quoting>

最后,我们必须确保在关闭当前引号后,字符串末尾仍有平衡的引号
(?:^|"|)\g<non_quoting>"\K\g<non_quoting>(?="\g<balanced_quotes>$)


终于完成了!

我们可以将"函数"定义和实际匹配组合在一起,以达到最终的正则表达式:

(?<escaped_quote>(?<!\\)\\\\{2}*"){0}(?<non_quoting>(?:\g<escaped_quote>|(?!\g<escaped_quote>)[^"])*){0}(?<balanced_quotes>\g<non_quoting>|(?:\g<non_quoting>"\g<non_quoting>){2}+){0}(?:^|"|)\g<non_quoting>"\K\g<non_quoting>(?="\g<balanced_quotes>$)

看它如何运作


最后的想法

这里需要注意的一件事是,即使您的正则表达式引擎不支持某些功能,您仍可以通过在函数调用中替换实现相同的正则表达式。唯一无处不在但您将需要的是\K

我希望阅读此文对每个人都是一个有趣的学习经历。


嗯,\\"s\"s\\"g" 这个好像不对。 - d0nut
自从你擅长发现我正则表达式中的问题以来,能不能再看一下我最近的修改,看它是否已经改好了呢? - d0nut
啊,好的。说实话我不太确定应该期望什么样的行为,所以这更像是一个问题而不是评论 :p - d0nut
@CarySwoveland,这是在SO上的一件事吗?xd - ndnenkov
@ndn 我必须得说,你在这个问题上下了很多功夫。你几乎完美地解决了它,并且对你的解决方案进行了很好的描述(使用命名组使其更加优雅)。但是还有一个 bug 需要你修复,哈哈,就是连续的引号似乎会破坏之后的平衡引号。 - Kana Ki
显示剩余12条评论

1

编辑

由于.NET正则表达式支持在lookbehind中进行无限重复,因此正则表达式为:

(?<!(.|\n)\G")(?<!(^|[^\\])(\\\\)*\\")(?:(?<=")(?:(?:\\\\|\\"|[^"])+?)(?=")|(?<=")(?="))

在.NET中,比Java更好地工作(因为使用间隔不是最佳解决方案)。

演示

之前的回答

我想我找到了一种方法来做到这一点,但只能用 Java 和正则表达式:

(?<!(.|\n)\G")(?<!(^|[^\\])(\\\\){0,20}\\")(?:(?<=")(?:(?:\\\\|\\"|[^"])+?)(?=")|(?<=")(?="))

这是基于我之前的尝试,它只能在Java中工作(据我所知),因为它使用了负回顾部分,这是该语言允许的语法。

正则表达式的解释:

正则表达式以两个负回顾开头,这应该确保正则表达式不会从先前引用的引号匹配,并且不会从/到转义引号匹配。

(?<!(.|\n)\G") - 这部分负责忽略引用先前引用的引号。因此,它是以下内容的负回顾:

  • 任何字符.也包括新行\n(但是如果您使用Java的DOTALL模式,则.就足够了),接着;
  • \G - 上一个匹配的结束位置或行的开始位置,因此如果另一个匹配在此特定字符上结束,则正则表达式不能在"之后匹配,
  • " - 引号,

((?<!(^|[^\\])(\\\\){0,20}\\") 负责忽略引用外的转义引号,因此它防止匹配从无效点开始。它是负向后查找:

  • (^|[^\\]) - 一行的开头,或者不是引号的字符(它是为了防止下一部分从反斜杠序列的中间位置匹配,例如 \\\\\\\\"xxx"),接着;
  • (\\\\){0,20} - 零个或多个(最多20个)两个反斜杠的集合(以确保它是转义的引号),接着;
  • \\ - 单个转义反斜杠,
在大多数语言中,回顾(lookbehind)是零长度的,并且需要具有固定的长度,因此不允许在其中使用限定符或间隔符(+*?{2,4})。然而,在Java中,可以使用?和区间,以及最小和最大长度。因此,在(\\\\){0,20}中的20是一个最大值,它可能更大,但我认为没有人会使用超过(甚至接近)20个双反斜杠。但仍然值得记住。在这个正则表达式中,这个结构用于匹配偶数个反斜杠,并确定引号前面的反斜杠是转义字符还是用于转义后面的字符。

这部分是匹配带有内容和不带内容的引号的替代方案。最后一部分(用于匹配不带内容)是更简单的一个:(?<=")(?=")),它应该匹配两个有效引号之间的点,但由于(?<!(.|\n)\G")的缘故,它将不会匹配连续的第二个和第三个引号之间的点(例如像""")。第一个替代方案略微复杂:

(?<=")(?:(?:\\\\|\\"|[^"])+?)(?=")匹配前后都带有引号的字符串。它包含以下内容:

  • (?<=") - 引号的正向回顾,
  • (?:(?:\\\\|\\"|[^"])+?) - 下面解释的备选项,
  • (?=") - 引号的正向预测,

(?:\\\\|\\"|[^"])+?)*是以下替代方案的备选项:

  • \\\\ - 转换为反斜杠,重要的是在匹配\"之前进行匹配,以避免将\\"匹配为\"的情况。
  • \\" - 引号和反斜杠,重要的是在匹配[^"]之前进行匹配,这样\"就会被匹配为引号的一部分;
  • [^"] - 任何不是引号符号的字符

Java中的Ideone演示。

RegexPlanet上的正则表达式演示 - 点击Java


这个在 "\\" 处会出错。 - ndnenkov
这真的很好,但我稍微修改了一下以使其更好地工作。我觉得把它编辑到我的答案中是不对的,因为我甚至都不知道\G的存在。这是我所做的。请随意使用它来编辑您的答案:(?<!.\G")(?<=")(?>\\.|.)*?(?=") - d0nut
@iismathwizard 谢谢,我很感激,但我不会使用它,因为:1)这不是我的答案,2)它也有一些缺陷demo,但原子分组是个好主意。 - m.cekiera
@m.cekiera 哦!我没有看到那些缺陷。谢谢你指出来:o - d0nut
它将在"\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\""处中断。使用{0,20}就像试图欺骗正则表达式之神一样。xd - ndnenkov
显示剩余2条评论

0

编辑:

我认为现在是正确的。

(?<!.\G")(?<="|\\\\")(?<![^\\]\\")((?>\\.|[^"])*?)(?=")

Regex101

{{链接1:Regex101}}


2
因为这不匹配引号之间的内容。因此,必须使用向前/向后查找来确定引号的存在,但不匹配它们。 - Kana Ki
我不理解这个。第二个\K是如何只删除最后一个 " 而不是之前所有匹配的内容?你可以解释一下吗? - ndnenkov
@ndn 啊,它没有。我会修复它的。我想我能做的最好的就是不匹配第一个引号,但是我对第二个引号无能为力。 - d0nut
@iismathwizard,不起作用。尝试使用"foo"bar"baz"。应该匹配foobaz - ndnenkov
@ndn 我希望我们能在那里期望一个空格或逗号...不确定这是否可能。 - d0nut
显示剩余6条评论

0
你可以采用以下一般方法。我已经在代码中添加了puts语句以显示正在发生的事情。
str = 'Hello "my" name is "Andy", nice to meet "Sally"'

r = /
    (       # start capture group 1
    .*?     # match >= 0 characters lazily 
    (?<=\") # match " in a positive lookbehind
    (.*?)   # match >= 0 characters lazily in capture group 2
    (?=\")  # match " in a positive lookahead
    .       # match one character
    )       # close capture group 1
    /x      # extended mode

a = []
s = str.dup
loop do
  break a unless s =~ r
  puts
  puts "$1 = |#{$1}|"
  puts "$2 = |#{$2}|"
  a << $2
  puts "a  = #{a}"
  s = s[$1.size..-1]
  puts "s  = |#{s}|"
end

$1 = |Hello "my"|
$2 = |my|
a  = ["my"]
s  = | name is "Andy", nice to meet "Sally"|

$1 = | name is "Andy"|
$2 = |Andy|
a  = ["my", "Andy"]
s  = |, nice to meet "Sally"|

$1 = |, nice to meet "Sally"|
$2 = |Sally|
a  = ["my", "Andy", "Sally"]
s  = ||
  #=> ["my", "Andy", "Sally"] 

关键在于匹配正向先行断言后面的一个字符。如果没有它,代码将返回:
["my", " name is ", "Andy", ", nice to meet ", "Sally"]

请注意,$1 的值表明,在正向先行断言后匹配的字符不包括在匹配中。此外,即使'Sally'后面没有字符,也会匹配', nice to meet "Sally"'

我不明白.匹配的是什么?@ndd解释说它在s中匹配了",考虑到先行断言是零宽度的,这是完全有道理的。


1
“.”匹配当前匹配的结束引号。如果没有它,开头的“.*?”只会匹配最后一个匹配的引号,基本上就像是通过引号分割字符串(也就是每个匹配只会消耗一个引号,并且下一个匹配会使用结束引号)。 - ndnenkov

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