0
\$\begingroup\$

I inherited the following regex:

    /[^.!?\s][^.!?\n]*(?:[.!?](?!['"]?\s|$)[^.!?]*)*[.!?]?['"]?(?=\s|$)/g

This regex is used to split sentences, and is dealing with things like quotes after periods at the end of a sentence (He said "I don't care.") correctly. I've been playing with this regex, but I'm not sure yet if everything that's in here is needed. It seems to me that a lot of "groups" could be removed to achieve the same result.

Obviously documentation on this regex (one of the core aspects of the product) is missing.

This regex is madness. Is it just madness or is it mad genius?

\$\endgroup\$
2
  • \$\begingroup\$ I don't think this question is answerable. You may not have documentation, however documentation is needed to be able to review code. Also I'm not sure a regular expression alone can be considered "runnable code". A lot depends on the language/environment/engine it runs in. \$\endgroup\$
    – RoToRa
    Commented Apr 16, 2021 at 10:15
  • \$\begingroup\$ I use regex101.com to investigate any .net regex: prnt.sc/_UzcaLj4RI_i \$\endgroup\$
    – xhafan
    Commented Feb 27 at 12:14

1 Answer 1

2
\$\begingroup\$

Enlightenment.

Don't be impressed by a pattern. Format it to something readable:

[^.!?\s] [^.!?\n]*

(?:
    [.!?] (?! ['"]? \s | $ )
    [^.!?]*
)*

[.!?]? ['"]? (?= \s | $ )

There are three simple parts. No madness here, this pattern isn't complicated, but before assuming parts are useless you have to fully understand what they do.

  • The first part [^.!?\s] [^.!?\n]* search a character that isn't a white-space or a punctuation mark, and then it consumes greedily all eventual following characters that aren't punctuation marks too or newlines. Why newlines? This last point may seem surprising but is eventually related to the context: the way the text is formatted. In other words, sentences are perhaps on one line only. Note that [^.!?\s] is the only thing that isn't optional in the pattern. Note also that since [^.!?\n]* is greedy, the next character after it can only be a punctuation mark, a newline or the end of the string.

  • The second part is an optional non-capturing group eventually repeated. Its goal is to accept punctuation marks that don't end the sentence, for instance things like:
    3.14, bestcatpics.maowmaow, bad enc?ding.
    [.!?] should not be followed by a space or a "closing" quote and a space or the end of the string as the negative lookahead (?! ['"]? \s | $ ) expresses it. [^.!?]* will only reach the last position before the next punctuation mark (or the end of the string). Note that this time the newline isn't excluded: it's inconsistent with the first part. The non-capturing group is repeated until there's no more "fake" punctuation mark.

  • The third and last part finishes the job consuming the punctuation mark that ends the sentence and the "closing" quote before a white-space or the end of the string. Punctuation mark and quote are optional.

Pattern Review.

From a technical point of view.

The pattern is well-designed to reduce the backtracking as possible: (A|B)* is unrolled to A* (BA*)*, where A is [^.!?] (with or without \n) and B is [.!?] with its lookahead assertion (?! ['"]? \s | $ ).
Writing ( [^.!?] | [.!?] (?! ['"]? \s | $ ) )* is shorter than [^.!?]* (?: [.!?] (?! ['"]? \s | $ ) [^.!?]* )* (about 9 chars) but is from far less efficient for a backtracking regex engine.

Other good point, all quantifiers are greedy and applied over restricted sets of characters, that is also more efficient than using reluctant quantifiers over permissive character classes (like the dot, [\s\S] or [^] in Javascript).

The only thing to deplore is the inconsistency between [^.!?\n] in the first part and [^.!?] in the second part. I really doubt there's a good reason behind. Also, (?=\s|$) is IMO better written like that: (?!\S) even if it looks less explicit.

About readability.

Most of regex engine provides a verbose mode that ignores white-spaces and inline comments in the pattern. Javascript doesn't have this feature yet, but for a long pattern like this one, nothing forbids to do that:

const pat = String.raw`
    [^.!?\s] [^.!?\n]* # start of the sentence

    (?: # "fake" punctuation mark
        [.!?] (?! ['"]? \s | $ )
        [^.!?]*
    )*

    [.!?]? ['"]? (?!\S) # end of the sentence`;
    
const re = RegExp.compile(pat.replace(/\s+|#.*/g, ''), 'g');

The approach.

This pattern may be the right solution in the specific context in which it is used. Nevertheless, and under no circumstances can it be considered a viable solution for isolating sentences from a plain text in natural language. The analysis of natural language (NLP: Natural Language Processing) is a whole part of computing with these own tools like NLTK. While sentence breaking may seem like a basic task, it may require tools such as dictionaries, such as statistics (Machine Learning), and even, who would have thought, regex.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

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