From 6f62ac219b715ca8a8a92dbc0de17ed80c8d8e91 Mon Sep 17 00:00:00 2001 From: Carsten Grohmann Date: Sun, 30 Aug 2026 16:28:55 +0200 Subject: [PATCH 1/2] Don't count parentheses in a character class as groups --- .../automated_tests/re/basic_pyre.py | 1 + .../automated_tests/re/basictests.py | 20 +++++++++++ transcrypt/modules/re/translate.py | 35 ++++++++++++++++--- 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/transcrypt/development/automated_tests/re/basic_pyre.py b/transcrypt/development/automated_tests/re/basic_pyre.py index 05d433a2c..494f079ea 100644 --- a/transcrypt/development/automated_tests/re/basic_pyre.py +++ b/transcrypt/development/automated_tests/re/basic_pyre.py @@ -25,6 +25,7 @@ def run (test): checkSearchWithGroups(test) checkMatchOps(test) checkMatchWithGroups(test) + checkGroupsInCharClass(test) # checkMatchWithNamedGroups(test) # !!! @JdeH temporarily disabled this checkFullMatchOps(test) checkFindAllOps(test) diff --git a/transcrypt/development/automated_tests/re/basictests.py b/transcrypt/development/automated_tests/re/basictests.py index 88d8bdfa2..1d3fbb69a 100644 --- a/transcrypt/development/automated_tests/re/basictests.py +++ b/transcrypt/development/automated_tests/re/basictests.py @@ -235,6 +235,26 @@ def checkMatchWithNamedGroups(test, flags = 0): m = r.match("adfs;") test.check(m) +def checkGroupsInCharClass(test, flags = 0): + """ Parentheses inside a character class don't open a capture group + """ + r = re.compile(r"(?P[a-z()]+) = (?P[\d()]+)", flags) + test.check(r.groups) + d = r.groupindex + __pragma__('skip') + d = convertMappingDict(d) + __pragma__('noskip') + test.check( d ) + + m = r.search("a(b)c = 4(2)") + test.check( m.groups() ) + test.check( m.group("key") ) + test.check( m.group("value") ) + + r = re.compile(r"[(](\w+)[)][^()]*", flags) + test.check(r.groups) + test.check( r.search("(abc)def").groups() ) + def checkMatchWithGroups(test, flags = 0): rgx = re.compile(r'(\w)(\w)(\w)?', flags) test.check(rgx.pattern) diff --git a/transcrypt/modules/re/translate.py b/transcrypt/modules/re/translate.py index d3a6da021..da0d1aa06 100644 --- a/transcrypt/modules/re/translate.py +++ b/transcrypt/modules/re/translate.py @@ -15,6 +15,9 @@ stringFlags = 'aiLmsux' +# A '[' directly behind one of these tokens is a literal one +LITERAL_BRACKET_TOKENS = ('\\', '(?#') + # Represents a regex group (e.g /()/, /(?:)/ /(?=), etc). # `start` and `end` is the index of the groups start and end token in the token list. class Group: @@ -162,11 +165,38 @@ def resolve(self): return self.name + paras +# Translates the character class up to the closing ']' of an already shifted '['. +# Returns the class as a string and the queue with the remaining characters. +# An unterminated class is consumed as a whole, leaving the error to JS. +def translateCharClass(queue): + idx = 0 + while idx < len(queue): + if queue[idx] == '\\': + idx += 2 + # '[]]' and '[^]]' are valid literals within a Python character class + elif queue[idx] == ']' and (idx == 0 or (idx == 1 and queue[0] == '^')): + idx += 1 + elif queue[idx] == ']': + idx += 1 + break + else: + idx += 1 + + return ''.join(queue[:idx]), queue[idx:] + + def shift(stack, queue): done = not bool(queue) if not done: - stack.append(Token(queue[0], [], True)) + char = queue[0] queue = queue[1:] + prevTokenName = stack[len(stack) - 1].name if stack else '' + # A character class becomes a single token, its content is no regex + if char == '[' and prevTokenName not in LITERAL_BRACKET_TOKENS: + charClass, queue = translateCharClass(queue) + stack.append(Token('[' + charClass)) + else: + stack.append(Token(char, [], True)) return stack, queue, done @@ -218,9 +248,6 @@ def shiftReduce(stack, queue, namedGroups, flags): stack = stack[:-1] - elif s1.name == '[' and s0.name == '^': - stack[-2:] = [Token('[^')] - elif s1.name == '(' and s0.name == '?': stack[-2:] = [Token('(?')] From c2b3ba4045859906c11d763fdb183107cf2e3c21 Mon Sep 17 00:00:00 2001 From: Carsten Grohmann Date: Sun, 30 Aug 2026 16:30:01 +0200 Subject: [PATCH 2/2] Escape a literal ']' at the start of a character class --- .../automated_tests/re/basic_pyre.py | 1 + .../automated_tests/re/basictests.py | 17 +++++++++++++++++ transcrypt/modules/re/translate.py | 6 ++++-- 3 files changed, 22 insertions(+), 2 deletions(-) diff --git a/transcrypt/development/automated_tests/re/basic_pyre.py b/transcrypt/development/automated_tests/re/basic_pyre.py index 494f079ea..3cba84b6d 100644 --- a/transcrypt/development/automated_tests/re/basic_pyre.py +++ b/transcrypt/development/automated_tests/re/basic_pyre.py @@ -26,6 +26,7 @@ def run (test): checkMatchOps(test) checkMatchWithGroups(test) checkGroupsInCharClass(test) + checkClosingBracketInCharClass(test) # checkMatchWithNamedGroups(test) # !!! @JdeH temporarily disabled this checkFullMatchOps(test) checkFindAllOps(test) diff --git a/transcrypt/development/automated_tests/re/basictests.py b/transcrypt/development/automated_tests/re/basictests.py index 1d3fbb69a..25602290a 100644 --- a/transcrypt/development/automated_tests/re/basictests.py +++ b/transcrypt/development/automated_tests/re/basictests.py @@ -255,6 +255,23 @@ def checkGroupsInCharClass(test, flags = 0): test.check(r.groups) test.check( r.search("(abc)def").groups() ) +def checkClosingBracketInCharClass(test, flags = 0): + """ A ']' right behind the '[' or its negating '^' is a literal one + """ + r = re.compile(r"[]x]+", flags) + test.check( r.findall("]x]a") ) + + r = re.compile(r"[^]x]+", flags) + test.check( r.findall("]x]ab") ) + + r = re.compile(r"a[]](\w)", flags) + test.check(r.groups) + test.check( r.search("a]b").groups() ) + + # without a second one the class stays unterminated + test.check(test.expectException( lambda: re.compile(r"[]", flags) )) + test.check(test.expectException( lambda: re.compile(r"[^]", flags) )) + def checkMatchWithGroups(test, flags = 0): rgx = re.compile(r'(\w)(\w)(\w)?', flags) test.check(rgx.pattern) diff --git a/transcrypt/modules/re/translate.py b/transcrypt/modules/re/translate.py index da0d1aa06..22eed9b71 100644 --- a/transcrypt/modules/re/translate.py +++ b/transcrypt/modules/re/translate.py @@ -173,9 +173,11 @@ def translateCharClass(queue): while idx < len(queue): if queue[idx] == '\\': idx += 2 - # '[]]' and '[^]]' are valid literals within a Python character class + # '[]]' and '[^]]' are valid literals within a Python character class, + # JS lacks that rule and would end the class at this ']' elif queue[idx] == ']' and (idx == 0 or (idx == 1 and queue[0] == '^')): - idx += 1 + queue.insert(idx, '\\') + idx += 2 elif queue[idx] == ']': idx += 1 break