scripts: Implemented recursive punescape nesting

The previous implementation of punescape nesting only supported one
layer, because I didn't want to rewrite the pure re.sub approach.

But regex is not a pushdown automaton!

I.e. it's impossible to match both of these correctly:

  %{a%}s%{b%}s
  %{a%{a%}s%}s

This rewrites punescape and psplit to properly parse the punescape
string, recursing when we encounter "%{" and terminating on "%}s".

As a plus this shows that the punescape grammar is sound. This was a bit
up in the air with the hacky regex globbing.
This commit is contained in:
Christopher Haster
2026-02-19 10:48:53 -06:00
parent 97470c9930
commit d23edafcfe
10 changed files with 690 additions and 285 deletions
+53 -27
View File
@@ -420,7 +420,7 @@ def si2(x):
# parse %-escaped strings
#
# attrs can override __getitem__ for lazy attr generation
def punescape(s, attrs=None):
def punescape(s, attrs=None, start=None, end=None, submatch=None):
pattern = re.compile(
'%' '(?P<rep>[0-9]*)' '(?P<pun>'
'[%ns]'
@@ -429,7 +429,8 @@ def punescape(s, attrs=None):
'|' 'U........'
'|' '\((?P<field>[^)]*)\)'
'(?P<format>[+\- #0-9\.]*[siIdboxXfFeEgG])'
'|' '\{(?P<substring>(?:[^%]|%[^}])*)%\}'
'|' '\{'
'|' '\}'
'(?P<subformat>[+\- #0-9\.]*[siIdboxXfFeEgG])' ')')
def format(f, v):
@@ -453,34 +454,59 @@ def punescape(s, attrs=None):
# note we need Python's new format syntax for binary
return ('{:%s}' % f).format(v)
def unescape(m):
if m.group('pun')[0] == '%': s = '%'
elif m.group('pun')[0] == 'n': s = '\n'
elif m.group('pun')[0] == 's': s = ' '
elif m.group('pun')[0] == 'x': s = chr(int(m.group('pun')[1:], 16))
elif m.group('pun')[0] == 'u': s = chr(int(m.group('pun')[1:], 16))
elif m.group('pun')[0] == 'U': s = chr(int(m.group('pun')[1:], 16))
elif m.group('pun')[0] == '(':
if attrs is not None:
try:
v = attrs[m.group('field')]
except KeyError:
return m.group()
s_ = []
i = start or 0
while i < (end if end is not None else len(s)):
m = pattern.match(s, i)
if m:
m_ = m.group('pun')[0]
i_ = m.end()
if m_ == '%': v_ = '%'
elif m_ == 'n': v_ = '\n'
elif m_ == 's': v_ = ' '
elif m_ == 'x': v_ = chr(int(m.group('pun')[1:], 16))
elif m_ == 'u': v_ = chr(int(m.group('pun')[1:], 16))
elif m_ == 'U': v_ = chr(int(m.group('pun')[1:], 16))
elif m_ == '(':
if attrs is not None:
try:
v = attrs[m.group('field')]
except KeyError:
s_.append(m.group())
i = i_
continue
else:
s_.append(m.group())
i = i_
continue
v_ = format(m.group('format'), v)
elif m_ == '{':
m__ = []
v = punescape(s, attrs, m.end(), None, m__)
if m__:
v_ = format(m__[0].group('subformat'), v)
i_ = m__[0].end()
else:
v_ = str(v)
i_ = end if end is not None else len(s)
elif m_ == '}':
if submatch is not None:
submatch.append(m)
break
else:
return m.group()
s = format(m.group('format'), v)
elif m.group('pun')[0] == '{':
v = punescape(m.group('substring'), attrs)
s = format(m.group('subformat'), v)
s_.append(m.group())
i = i_
continue
if m.group('rep'):
v_ = int(m.group('rep'), 10) * v_
s_.append(v_)
i = i_
else:
return m.group()
s_.append(s[i])
i += 1
if m.group('rep'):
s = int(m.group('rep'), 10) * s
return s
return re.sub(pattern, unescape, s)
return ''.join(s_)