From ad2a0514241fc64f1be73a1071cf94bcac98e990 Mon Sep 17 00:00:00 2001 From: David Hong Date: Wed, 5 Aug 2026 22:16:44 -0700 Subject: [PATCH] fix: preserve non-BMP unicode characters (emojis) in double-quoted strings The ruyaml emitter (v0.91.0, latest on PyPI) escapes supplementary plane characters (U+10000-U+10FFFF, including all emojis) in double-quoted strings as \Uxxxxxxxx sequences because its allow_unicode range only covers up to U+FFFD. This is fixed on ruyaml's main branch but not yet released. Work around it by post-processing the emitter output to restore escaped non-BMP unicode sequences back to their original characters. Fixes lyz-code/yamlfix#300 Fixes lyz-code/yamlfix#227 --- src/yamlfix/adapters.py | 18 ++++++++++++++++++ tests/unit/test_services.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/src/yamlfix/adapters.py b/src/yamlfix/adapters.py index dbb845b..4abfbe5 100644 --- a/src/yamlfix/adapters.py +++ b/src/yamlfix/adapters.py @@ -15,6 +15,12 @@ log = logging.getLogger(__name__) +# Pattern matching escaped non-BMP unicode sequences produced by ruyaml's emitter. +# ruyaml (v0.91.0) only allows characters up to U+FFFD, escaping supplementary +# plane characters (emojis, etc.) as \Uxxxxxxxx. This is fixed on ruyaml's main +# branch but not yet released to PyPI. +_UNICODE_ESCAPE_RE = re.compile(r"[\\]U([0-9A-Fa-f]{8})") + class Yaml: """Adapter that holds the configured ruaml yaml fixer.""" @@ -352,6 +358,7 @@ def fix(self, source_code: str) -> str: self._restore_truthy_strings, self._restore_jinja_variables, self._restore_double_exclamations, + self._restore_unicode_escapes, self._fix_comments, self._fix_flow_style_lists, self._fix_whitelines, @@ -718,6 +725,17 @@ def _restore_double_exclamations(source_code: str) -> str: return "\n".join(fixed_source_lines) + @staticmethod + def _restore_unicode_escapes(source_code: str) -> str: + r"""Restore escaped non-BMP unicode sequences to their original characters. + + The ruyaml emitter (v0.91.0) escapes supplementary plane characters + (U+10000 to U+10FFFF, including emojis) in double-quoted strings as + \Uxxxxxxxx sequences. This converts them back to the actual characters. + """ + log.debug("Restoring unicode escapes...") + return _UNICODE_ESCAPE_RE.sub(lambda m: chr(int(m.group(1), 16)), source_code) + @staticmethod def _add_newline_at_end_of_file(source_code: str) -> str: """Ensures that the file ends with exactly one newline. diff --git a/tests/unit/test_services.py b/tests/unit/test_services.py index ca23f1b..548322b 100644 --- a/tests/unit/test_services.py +++ b/tests/unit/test_services.py @@ -494,6 +494,7 @@ def test_fix_code_functions_emit_debug_logs( "Restoring truthy strings...", "Restoring jinja2 variables...", "Restoring double exclamations...", + "Restoring unicode escapes...", "Fixing comments...", "Fixing top level lists...", "Fixing flow-style lists...", @@ -619,6 +620,33 @@ def test_fix_code_respects_comment_symbol_in_strings_with_double_quotes( assert result == desired_source + def test_fix_code_preserves_unicode_emojis_in_double_quoted_strings( + self, + ) -> None: + r""" + Given: Code with emojis (non-BMP unicode) in double-quoted strings + When: fix_code is run with preserve_quotes=True + Then: The emojis are preserved, not escaped to \Uxxxxxxxx sequences + + Regression test for https://github.com/lyz-code/yamlfix/issues/300 + """ + source = dedent( + """\ + --- + stages: + - "🔬 checks" + - "🚀 Release" + """ + ) + config = YamlfixConfig() + config.preserve_quotes = True + + result = fix_code(source, config) + + assert "🔬" in result + assert "🚀" in result + assert "\\U" not in result + def test_fix_code_respects_jinja_variables_with_equals( self, ) -> None: