From e71110ab240a303d14097e92d2d20e84288cab19 Mon Sep 17 00:00:00 2001 From: chuenchen309 <48723787+chuenchen309@users.noreply.github.com> Date: Thu, 16 Jul 2026 20:12:57 +0800 Subject: [PATCH] fix(functemplate): empty ${} inside a call argument truncates the argument list `Parser.parse_symbol` handled "no closing brace" and "empty identifier" in the same branch, emitting `${` as literal text without advancing past the closing brace. The unconsumed `}` was then read by `parse_argument_list` as the enclosing call's terminator, so the argument list was cut short and the remainder of the template leaked out as raw text: %foo{a${}b} -> Call('foo', ['a${']) + 'b}' (was 'A${b}' evaluated) %foo{a${}b,baz} -> parsed as one argument + raw 'b,baz}' At the top level `}` is not a terminator, which is why `a ${} b` parsed correctly and the existing test did not catch this. Split the two cases so an empty identifier consumes through the closing brace, keeping `${}` as literal text in both contexts. Co-Authored-By: Claude Opus 4.8 (1M context) --- beets/util/functemplate.py | 9 +++++++-- docs/changelog.rst | 3 +++ test/util/test_functemplate.py | 13 +++++++++++++ 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/beets/util/functemplate.py b/beets/util/functemplate.py index 5547bcb0e5..228af9352f 100644 --- a/beets/util/functemplate.py +++ b/beets/util/functemplate.py @@ -380,8 +380,13 @@ def parse_symbol(self): # A symbol like ${this}. self.pos += 1 # Skip opening. closer = self.string.find(GROUP_CLOSE, self.pos) - if closer == -1 or closer == self.pos: - # No closing brace found or identifier is empty. + if closer == -1: + # No closing brace found. + self.parts.append(self.string[start_pos : self.pos]) + elif closer == self.pos: + # Empty identifier: consume through the closing brace so it + # cannot be mistaken for an enclosing call's terminator. + self.pos = closer + 1 self.parts.append(self.string[start_pos : self.pos]) else: # Closer found. diff --git a/docs/changelog.rst b/docs/changelog.rst index 096a35a6db..b94d923c55 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -95,6 +95,9 @@ Bug fixes valid date/time string" error instead of crashing with an uncaught ``KeyError``. A ``|`` was being accepted as a relative-date unit due to a regular expression character-class typo. +- Path formats: an empty ``${}`` inside a function call argument no longer + truncates the argument list and leaks the rest of the template as literal + text. .. For plugin developers diff --git a/test/util/test_functemplate.py b/test/util/test_functemplate.py index 243c03740c..a377feaa94 100644 --- a/test/util/test_functemplate.py +++ b/test/util/test_functemplate.py @@ -115,6 +115,19 @@ def test_unclosed_braces_symbol(self): def test_empty_braces_symbol(self): assert list(_normparse("a ${} b")) == ["a ${} b"] + def test_empty_braces_symbol_inside_call_arg(self): + parts = list(_normparse("%foo{a${}b}")) + assert len(parts) == 1 + self._assert_call(parts[0], "foo", 1) + assert list(_normexpr(parts[0].args[0])) == ["a${}b"] + + def test_empty_braces_symbol_inside_call_arg_list(self): + parts = list(_normparse("%foo{a${}b,baz}")) + assert len(parts) == 1 + self._assert_call(parts[0], "foo", 2) + assert list(_normexpr(parts[0].args[0])) == ["a${}b"] + assert list(_normexpr(parts[0].args[1])) == ["baz"] + def test_call_without_args_at_end(self): assert list(_normparse("foo %bar")) == ["foo %bar"]