From f02becaeffe7a727a71c56765672e6d43821d6e3 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Thu, 15 Oct 2026 07:49:36 +0300 Subject: [PATCH] perf(theme): walk scope prefixes by last dot in TokenStyleResolver _scopePrefixes split the scope into a list and built every prefix with sublist(0, i).join('.') up front, for each uncached scope lookup. The resolver cuts the scope at the last dot, one prefix at a time, and stops at the first rule that matches. The order of prefixes is the same, including empty segments; a test pins that. Closes #1530 --- .../theme/parser/token_style_resolver.dart | 16 +++++------- .../parser/token_style_resolver_test.dart | 26 +++++++++++++++++++ 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/lib/core/theme/parser/token_style_resolver.dart b/lib/core/theme/parser/token_style_resolver.dart index acb48322..f2a8b8a4 100644 --- a/lib/core/theme/parser/token_style_resolver.dart +++ b/lib/core/theme/parser/token_style_resolver.dart @@ -22,21 +22,19 @@ class TokenStyleResolver { _cache.putIfAbsent(scope, () => _resolveUncached(scope)); TextStyle _resolveUncached(String scope) { - for (final prefix in _scopePrefixes(scope)) { + // `a.b.c` -> `a.b` -> `a`: cut at the last dot instead of splitting into a + // list and joining sublists (#1530). + var prefix = scope; + while (true) { for (final rule in _rules) { if (rule.scopes.contains(prefix)) { return _styleFromRule(rule); } } + final dot = prefix.lastIndexOf('.'); + if (dot < 0) return _defaultStyle; + prefix = prefix.substring(0, dot); } - return _defaultStyle; - } - - List _scopePrefixes(String scope) { - final parts = scope.split('.'); - return [ - for (var i = parts.length; i >= 1; i--) parts.sublist(0, i).join('.'), - ]; } TextStyle _styleFromRule(TokenColorRule rule) { diff --git a/test/core/theme/parser/token_style_resolver_test.dart b/test/core/theme/parser/token_style_resolver_test.dart index c2badd7e..cee943f6 100644 --- a/test/core/theme/parser/token_style_resolver_test.dart +++ b/test/core/theme/parser/token_style_resolver_test.dart @@ -37,4 +37,30 @@ void main() { final b = resolver.resolve('string.quoted.double'); expect(identical(a, b), isTrue); }); + + test('walks prefixes by dot, including empty segments (#1530)', () { + const rules = [ + TokenColorRule(scopes: ['a'], foreground: '#010101'), + TokenColorRule(scopes: ['a.b'], foreground: '#020202'), + TokenColorRule(scopes: ['a.'], foreground: '#030303'), + TokenColorRule(scopes: [''], foreground: '#040404'), + TokenColorRule(scopes: ['x.y.z'], foreground: '#050505'), + ]; + final resolver = TokenStyleResolver( + rules: rules, + defaultStyle: const TextStyle(color: Color(0xFFFFFFFF)), + ); + + // The longest prefix wins. + expect(resolver.resolve('a.b.c.d').color, const Color(0xFF020202)); + expect(resolver.resolve('a.c.d').color, const Color(0xFF010101)); + expect(resolver.resolve('x.y.z.w').color, const Color(0xFF050505)); + // `a..b` -> `a.` -> `a`: the empty segment is a prefix of its own. + expect(resolver.resolve('a..b').color, const Color(0xFF030303)); + // The leading dot gives the empty prefix; a bare empty scope is it too. + expect(resolver.resolve('.q').color, const Color(0xFF040404)); + expect(resolver.resolve('').color, const Color(0xFF040404)); + // Nothing matches: the default style. + expect(resolver.resolve('zzz.top').color, const Color(0xFFFFFFFF)); + }); }