Skip to content

Commit 9a3613e

Browse files
authored
Add POSIX ERE grammar support to the C++ regex parser
1 parent e728c71 commit 9a3613e

6 files changed

Lines changed: 866 additions & 409 deletions

File tree

cpp/ql/lib/semmle/code/cpp/regex/RegexFlowConfigs.qll

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -366,8 +366,12 @@ predicate hasMultilineFlag(StringLiteral regex) {
366366

367367
/**
368368
* Holds if `regex` is constructed with an explicit non-ECMAScript grammar
369-
* flag (`basic`, `extended`, `awk`, `grep`, or `egrep`). The Phase 1 parser
370-
* only models ECMAScript, so such regexes should be excluded from analysis.
369+
* flag (`basic`, `extended`, `awk`, `grep`, or `egrep`).
370+
*
371+
* This predicate remains a purely flag-level classification and does not
372+
* gate the parser directly — the parser now uses `regexGrammar` to decide
373+
* which dialect to apply. ECMAScript and ERE (`extended`/`egrep`/`awk`)
374+
* are both modelled; only BRE (`basic`/`grep`) is currently excluded.
371375
*/
372376
predicate hasNonEcmaScriptGrammarFlag(StringLiteral regex) {
373377
exists(string g | g = ["basic", "extended", "awk", "grep", "egrep"] |
@@ -382,21 +386,20 @@ predicate hasNonEcmaScriptGrammarFlag(StringLiteral regex) {
382386
/**
383387
* The `std::regex` grammar dialects that the C++ regex parser is aware of.
384388
*
385-
* - `Ecma()` — ECMAScript, the default grammar used by `std::regex` and the
386-
* only grammar currently parsed. Selected either implicitly
387-
* (no explicit grammar flag) or explicitly via
388-
* `std::regex_constants::ECMAScript`.
389-
* - `Bre()` — POSIX Basic Regular Expressions (selected via the `basic` or
390-
* `grep` flags).
389+
* - `Ecma()` — ECMAScript, the default grammar used by `std::regex`.
390+
* Selected either implicitly (no explicit grammar flag) or
391+
* explicitly via `std::regex_constants::ECMAScript`.
392+
* - `Bre()` — POSIX Basic Regular Expressions (selected via the `basic`
393+
* or `grep` flags). Not yet modelled by the parser; regexes
394+
* in this grammar are excluded from analysis.
391395
* - `Ere()` — POSIX Extended Regular Expressions (selected via the
392-
* `extended`, `egrep`, or `awk` flags).
396+
* `extended`, `egrep`, or `awk` flags). Modelled by
397+
* `EreRegExp`.
393398
*
394-
* `Bre()` and `Ere()` are scaffolding for future phases that will add POSIX
395-
* grammar support. Today the parser still excludes non-ECMAScript regexes
396-
* (see `hasNonEcmaScriptGrammarFlag`), so in practice every parsed regex
397-
* classifies as `Ecma()` and only the `Ecma()` branch of `regexGrammar` is
398-
* exercised — the `Bre()`/`Ere()` branches exist purely so the classifier
399-
* agrees with the flag-detection helpers below.
399+
* The `Ecma()` and `Ere()` cases are both exercised by the parser today.
400+
* The `Bre()` case is scaffolding for a future phase and is not exercised;
401+
* regexes classified as `Bre()` are excluded by the `RegExp` characteristic
402+
* predicate.
400403
*/
401404
newtype TRegexGrammar =
402405
Ecma() or
@@ -412,10 +415,9 @@ newtype TRegexGrammar =
412415
* - `extended` / `egrep` / `awk` → `Ere()`
413416
* - anything else (default, explicit `ECMAScript`, or unresolved) → `Ecma()`
414417
*
415-
* Because the parser still gates on `not hasNonEcmaScriptGrammarFlag`, every
416-
* literal that is actually parsed today classifies as `Ecma()`. The
417-
* `Bre()`/`Ere()` cases are defined only so the classifier is complete for
418-
* later phases; those branches are not exercised by the current parser.
418+
* The parser gates on `regexGrammar in [Ecma(), Ere()]`, so both branches
419+
* are exercised by the parser today. Regexes classified as `Bre()` are
420+
* excluded from analysis until a future phase adds BRE support.
419421
*/
420422
TRegexGrammar regexGrammar(StringLiteral regex) {
421423
if containsRegexFlag(getConstructionFlagArg(regex), ["basic", "grep"])

cpp/ql/lib/semmle/code/cpp/regex/internal/ParseRegExp.qll

Lines changed: 250 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -7,24 +7,29 @@
77
* classes, POSIX bracket sub-expressions, character/normal-character
88
* tokenization, and failure-to-parse reporting) purely in terms of a small
99
* set of **dialect hook** abstract predicates. Concrete grammar dialects
10-
* (currently only ECMAScript, implemented by `EcmaRegExp`) supply the raw
11-
* lexical decisions — "is this position an escape backslash?", "is this a
12-
* group open?", "is this a quantifier?", etc. — by overriding those hooks.
10+
* supply the raw lexical decisions — "is this position an escape
11+
* backslash?", "is this a group open?", "is this a quantifier?", etc. — by
12+
* overriding those hooks.
1313
*
14-
* The only grammar dialect implemented today is ECMAScript (the default
15-
* used by `std::regex`, i.e. `std::regex_constants::ECMAScript`). The other
16-
* `std::regex` grammars (`basic`, `extended`, `awk`, `grep`, `egrep`) are
14+
* The grammar dialects modelled today are:
15+
* - ECMAScript (`EcmaRegExp`), the default used by `std::regex` (i.e.
16+
* `std::regex_constants::ECMAScript`); and
17+
* - POSIX Extended Regular Expressions (`EreRegExp`), selected via the
18+
* `extended`, `egrep`, and `awk` flags.
19+
*
20+
* POSIX Basic Regular Expressions (BRE, selected via `basic`/`grep`) are
1721
* still excluded by the `RegExp` characteristic predicate; the hook-based
18-
* split exists so a later phase can plug in POSIX BRE/ERE without touching
19-
* the shared core.
22+
* split exists so a later phase can plug in BRE without touching the
23+
* shared core.
2024
*
21-
* The single-grammar-per-literal assumption is baked into this phase:
22-
* because every parsed literal is `Ecma()` today, `EcmaRegExp` is the sole
23-
* concrete subclass and there is no overlap. The "same literal used under
24-
* two different grammars" case is not yet handled and is deferred to the
25-
* phase that actually introduces a second concrete grammar subclass; at
26-
* that point this file will need to revisit how `TRegExpParent` identity
27-
* relates to grammar.
25+
* The single-grammar-per-literal assumption is still baked into this phase:
26+
* because each parsed literal's grammar is uniquely determined by
27+
* `regexGrammar` (a functional classifier over the construction-site flag
28+
* argument), a literal is either an `EcmaRegExp` or an `EreRegExp`, never
29+
* both. The "same literal used under two different grammars" case is not
30+
* yet handled and is deferred to the phase that actually introduces
31+
* overlap; at that point this file will need to revisit how
32+
* `TRegExpParent` identity relates to grammar.
2833
*/
2934

3035
import cpp
@@ -40,19 +45,20 @@ private import semmle.code.cpp.regex.RegexFlowConfigs as RFC
4045
* A `StringLiteral` is treated as a regex only when dataflow indicates it
4146
* flows to a `std::basic_regex` construction/assignment or to a
4247
* `regex_match`/`regex_search`/`regex_replace`/iterator call. Regexes
43-
* constructed with an explicit non-ECMAScript grammar flag are excluded,
44-
* since the parser only models the ECMAScript dialect (see
45-
* `EcmaRegExp`).
48+
* constructed with a grammar flag the parser does not yet model (currently
49+
* POSIX Basic Regular Expressions — the `basic` and `grep` flags) are
50+
* excluded.
4651
*
4752
* This class is abstract: its structural predicates are expressed in terms
4853
* of dialect hooks (see below), and concrete grammar subclasses supply the
49-
* dialect-specific token-recognition behavior. The sole concrete subclass
50-
* today is `EcmaRegExp`.
54+
* dialect-specific token-recognition behavior. The concrete subclasses are
55+
* `EcmaRegExp` (for ECMAScript) and `EreRegExp` (for POSIX Extended
56+
* Regular Expressions).
5157
*/
5258
abstract class RegExp extends StringLiteral {
5359
RegExp() {
5460
RFC::usedAsRegex(this) and
55-
not RFC::hasNonEcmaScriptGrammarFlag(this)
61+
(RFC::regexGrammar(this) = RFC::Ecma() or RFC::regexGrammar(this) = RFC::Ere())
5662
}
5763

5864
/** Gets the `i`th character of this regex string. */
@@ -912,11 +918,12 @@ abstract class RegExp extends StringLiteral {
912918
* The ECMAScript-grammar concrete `RegExp` implementation. Supplies all
913919
* dialect hooks with the ECMAScript token-recognition behavior.
914920
*
915-
* Because the core `RegExp` class already excludes non-ECMAScript-grammar
916-
* literals via `not hasNonEcmaScriptGrammarFlag`, and `regexGrammar(this)`
917-
* returns `Ecma()` for anything not explicitly tagged as `basic`/`grep`/
918-
* `extended`/`egrep`/`awk`, this class currently covers every `RegExp`
919-
* instance in the database — `EcmaRegExp` is the sole concrete subclass.
921+
* Selected for regex literals whose construction-site flag argument does
922+
* not specify a POSIX grammar (i.e. anything not tagged as
923+
* `basic`/`grep`/`extended`/`egrep`/`awk`), matching the default `std::regex`
924+
* grammar. `EcmaRegExp` and `EreRegExp` are the two concrete subclasses of
925+
* `RegExp`; the grammar of a given literal is determined uniquely by
926+
* `regexGrammar`.
920927
*/
921928
class EcmaRegExp extends RegExp {
922929
EcmaRegExp() { RFC::regexGrammar(this) = RFC::Ecma() }
@@ -1262,3 +1269,220 @@ class EcmaRegExp extends RegExp {
12621269
this.named_backreference(start, end, result)
12631270
}
12641271
}
1272+
1273+
// ===========================================================================
1274+
// POSIX Extended Regular Expressions (ERE) dialect
1275+
// ===========================================================================
1276+
1277+
/**
1278+
* The POSIX Extended Regular Expressions concrete `RegExp` implementation.
1279+
* Supplies all dialect hooks with the ERE token-recognition behavior.
1280+
*
1281+
* ERE is selected via the `std::regex_constants` flags `extended`, `egrep`,
1282+
* and `awk` (all three parse the same grammar — they differ only in whether
1283+
* the matching engine is treated as backtracking; see
1284+
* `RegexFlowConfigs::isBacktrackingEngine`).
1285+
*
1286+
* ERE is largely subtractive relative to ECMAScript:
1287+
*
1288+
* - Same grouping (`(...)`), alternation (`|`), and quantifiers
1289+
* (`*`, `+`, `?`, `{n}`, `{n,}`, `{n,m}`).
1290+
* - Same character classes `[...]` (including the shared POSIX bracket
1291+
* sub-expressions handled entirely in the core: `[:class:]`, `[.a.]`,
1292+
* `[=a=]`).
1293+
* - Same anchors `^`, `$` and wildcard `.`.
1294+
*
1295+
* ERE has **no**:
1296+
* - Class-shorthand escapes `\d`, `\w`, `\s`, `\D`, `\W`, `\S`.
1297+
* - Word-boundary anchors `\b`, `\B`.
1298+
* - Numeric or hex/unicode/octal escapes (`\1`, `\xNN`, `\uNNNN`, `\0`).
1299+
* - Back-references (numbered or named).
1300+
* - Look-around (`(?=`, `(?!`, `(?<=`, `(?<!`).
1301+
* - Non-capturing groups (`(?:...)`).
1302+
* - Named capturing groups (`(?<name>...)`).
1303+
* - Lazy quantifier suffix (`*?`, `+?`, `??`, `{n,m}?`).
1304+
*
1305+
* A backslash in front of any character produces a literal escaped
1306+
* character (two-character span): so `\.` matches a literal `.`, `\(`
1307+
* matches a literal `(`, and so on. This is the standard interpretation of
1308+
* `\` in ERE for metacharacters; behavior for `\` in front of an ordinary
1309+
* character is implementation-defined but is uniformly treated here as a
1310+
* literal escape of the following character so the tokenizer is total.
1311+
*/
1312+
class EreRegExp extends RegExp {
1313+
EreRegExp() { RFC::regexGrammar(this) = RFC::Ere() }
1314+
1315+
override RFC::TRegexGrammar getGrammar() { result = RFC::Ere() }
1316+
1317+
// ---------------------------------------------------------------------------
1318+
// Escaping
1319+
// ---------------------------------------------------------------------------
1320+
1321+
/**
1322+
* Helper predicate for `escapingChar`.
1323+
* Returns `true` if the character at position `pos` is an active backslash
1324+
* (i.e., it escapes the next character). Uses a boolean to avoid negation
1325+
* in recursive calls.
1326+
*/
1327+
private boolean escaping(int pos) {
1328+
pos = -1 and result = false
1329+
or
1330+
this.getChar(pos) = "\\" and result = this.escaping(pos - 1).booleanNot()
1331+
or
1332+
this.getChar(pos) != "\\" and result = false
1333+
}
1334+
1335+
override predicate escapingChar(int pos) { this.escaping(pos) = true }
1336+
1337+
// ---------------------------------------------------------------------------
1338+
// Escaped characters
1339+
//
1340+
// ERE has no numeric, hex, unicode, or octal escapes and no back-references.
1341+
// Every `\X` is a simple two-character escape yielding a literal X.
1342+
// ---------------------------------------------------------------------------
1343+
1344+
override predicate escapedCharacter(int start, int end) {
1345+
this.escapingChar(start) and
1346+
exists(this.getChar(start + 1)) and
1347+
end = start + 2
1348+
}
1349+
1350+
// ---------------------------------------------------------------------------
1351+
// Special (meta) characters
1352+
//
1353+
// ERE has only the position-assertion / wildcard specials `^`, `$`, `.`.
1354+
// There are no word-boundary escapes `\b` / `\B`.
1355+
// ---------------------------------------------------------------------------
1356+
1357+
override predicate specialCharacter(int start, int end, string char) {
1358+
not this.inCharSet(start) and
1359+
this.character(start, end) and
1360+
end = start + 1 and
1361+
char = this.getChar(start) and
1362+
(char = "$" or char = "^" or char = ".")
1363+
}
1364+
1365+
// ---------------------------------------------------------------------------
1366+
// Quantifiers
1367+
//
1368+
// ERE has no lazy suffix, so `qualifier` and `short_qualifier` coincide.
1369+
// ---------------------------------------------------------------------------
1370+
1371+
override predicate qualifier(int start, int end, boolean maybe_empty, boolean may_repeat_forever) {
1372+
this.short_qualifier(start, end, maybe_empty, may_repeat_forever)
1373+
}
1374+
1375+
override predicate short_qualifier(
1376+
int start, int end, boolean maybe_empty, boolean may_repeat_forever
1377+
) {
1378+
(
1379+
this.getChar(start) = "+" and maybe_empty = false and may_repeat_forever = true
1380+
or
1381+
this.getChar(start) = "*" and maybe_empty = true and may_repeat_forever = true
1382+
or
1383+
this.getChar(start) = "?" and maybe_empty = true and may_repeat_forever = false
1384+
) and
1385+
end = start + 1 and
1386+
not this.escapingChar(start - 1)
1387+
or
1388+
exists(string lower, string upper |
1389+
this.multiples(start, end, lower, upper) and
1390+
(if lower = "" or lower.toInt() = 0 then maybe_empty = true else maybe_empty = false) and
1391+
if upper = "" then may_repeat_forever = true else may_repeat_forever = false
1392+
)
1393+
}
1394+
1395+
/**
1396+
* Holds if `[start, end)` is a `{n}`, `{n,m}`, or `{n,}` quantifier.
1397+
*
1398+
* In ERE, `{...}` is unconditionally a quantifier — there is no `\{...\}`
1399+
* literal-brace form (that would be BRE). A backslash-escaped `\{` is a
1400+
* literal `{`, so we must not treat it as a quantifier here.
1401+
*/
1402+
override predicate multiples(int start, int end, string lower, string upper) {
1403+
not this.escapingChar(start - 1) and
1404+
exists(string text, string match, string inner |
1405+
text = this.getText() and
1406+
end = start + match.length() and
1407+
inner = match.substring(1, match.length() - 1)
1408+
|
1409+
match = text.regexpFind("\\{[0-9]+\\}", _, start) and
1410+
lower = inner and
1411+
upper = lower
1412+
or
1413+
match = text.regexpFind("\\{[0-9]*,[0-9]*\\}", _, start) and
1414+
exists(int commaIndex |
1415+
commaIndex = inner.indexOf(",") and
1416+
lower = inner.prefix(commaIndex) and
1417+
upper = inner.suffix(commaIndex + 1)
1418+
)
1419+
)
1420+
}
1421+
1422+
// ---------------------------------------------------------------------------
1423+
// Groups
1424+
//
1425+
// ERE has only simple capturing groups `(...)`. It has no `(?:...)`,
1426+
// no `(?<name>...)`, and no look-around forms.
1427+
// ---------------------------------------------------------------------------
1428+
1429+
override predicate isOptionDivider(int i) { this.nonEscapedCharAt(i) = "|" }
1430+
1431+
override predicate isGroupEnd(int i) { this.nonEscapedCharAt(i) = ")" and not this.inCharSet(i) }
1432+
1433+
override predicate isGroupStart(int i) { this.nonEscapedCharAt(i) = "(" and not this.inCharSet(i) }
1434+
1435+
override predicate group_start(int start, int end) { this.simple_group_start(start, end) }
1436+
1437+
/** `(...)` – simple capturing group. */
1438+
override predicate simple_group_start(int start, int end) {
1439+
this.isGroupStart(start) and end = start + 1
1440+
}
1441+
1442+
/** ERE has no non-capturing group form. */
1443+
override predicate non_capturing_group_start(int start, int end) { none() }
1444+
1445+
/** ERE has no named capturing group form. */
1446+
override predicate ecma_named_group_start(int start, int end) { none() }
1447+
1448+
/** ERE has no look-around. */
1449+
override predicate lookahead_assertion_start(int start, int end) { none() }
1450+
1451+
/** ERE has no look-around. */
1452+
override predicate negative_lookahead_assertion_start(int start, int end) { none() }
1453+
1454+
/** ERE has no look-around. */
1455+
override predicate lookbehind_assertion_start(int start, int end) { none() }
1456+
1457+
/** ERE has no look-around. */
1458+
override predicate negative_lookbehind_assertion_start(int start, int end) { none() }
1459+
1460+
/**
1461+
* Gets the 1-based index of the capture group at `[start, end)`. In ERE
1462+
* every `(...)` group is a capturing group, numbered by left-to-right
1463+
* position of the opening `(`.
1464+
*/
1465+
override int getGroupNumber(int start, int end) {
1466+
this.group(start, end) and
1467+
result = count(int i | this.group(i, _) and i < start) + 1
1468+
}
1469+
1470+
/** ERE has no named groups. */
1471+
override string getGroupName(int start, int end) { none() }
1472+
1473+
// ---------------------------------------------------------------------------
1474+
// Back-references
1475+
//
1476+
// ERE has no back-references (neither numbered nor named).
1477+
// ---------------------------------------------------------------------------
1478+
1479+
override predicate numbered_backreference(int start, int end, int value) { none() }
1480+
1481+
override predicate named_backreference(int start, int end, string name) { none() }
1482+
1483+
override predicate backreference(int start, int end) { none() }
1484+
1485+
override int getBackrefNumber(int start, int end) { none() }
1486+
1487+
override string getBackrefName(int start, int end) { none() }
1488+
}

0 commit comments

Comments
 (0)