diff --git a/scripts/localize_changes.py b/scripts/localize_changes.py index 9b9bcd0b8248..2930e5efb61f 100644 --- a/scripts/localize_changes.py +++ b/scripts/localize_changes.py @@ -28,7 +28,7 @@ r'(?P"(?:\\.|[^"\\])*")' ) -SWIFT_DEFAULT = re.compile(r'defaultValue\s*:\s*(?P"(?:\\.|[^"\\])*")') +SWIFT_DEFAULT = re.compile(r'defaultValue\s*:\s*(?P"""\n[\s\S]*?\n[ \t]*"""|"(?:\\.|[^"\\])*")') SWIFT_COMMENT = re.compile(r'comment\s*:\s*(?P"(?:\\.|[^"\\])*")') WEB_LOCALES = re.compile(r"export\s+const\s+locales\s*=\s*\[(?P[\s\S]*?)\]\s*as\s+const") QUOTED = re.compile(r'"((?:\\.|[^"\\])*)"|\'((?:\\.|[^\'\\])*)\'') @@ -107,9 +107,12 @@ def base_text(root: Path, base: str, path: str) -> str: def decode_swift_string(literal: str) -> str: - if not (literal.startswith('"') and literal.endswith('"')): + if literal.startswith('"""'): + value = multiline_swift_body(literal) + elif literal.startswith('"') and literal.endswith('"'): + value = literal[1:-1] + else: raise ValueError("unsupported Swift string literal") - value = literal[1:-1] if "\\(" in value: raise ValueError("interpolated defaultValue requires manual catalog review") result: list[str] = [] @@ -128,12 +131,38 @@ def decode_swift_string(literal: str) -> str: return "".join(result) +def multiline_swift_body(literal: str) -> str: + """Strip a multi-line literal's delimiters and the closing delimiter's indentation, as Swift does.""" + lines = literal.split("\n") + indent = lines[-1][: -len('"""')] + if lines[0] != '"""' or indent.strip(): + raise ValueError("unsupported Swift string literal") + body = [] + for line in lines[1:-1]: + if line.strip() and not line.startswith(indent): + raise ValueError("unsupported Swift string literal") + if line.endswith("\\"): + raise ValueError("unsupported Swift escape in defaultValue") + body.append(line[len(indent):] if line.strip() else "") + return "\n".join(body) + + def swift_call_suffix(text: str, start: int) -> str: """Read through the closing call parenthesis, respecting nested calls and strings.""" depth = 1 quoted = escaped = False + skip = 0 for index in range(start, len(text)): char = text[index] + if skip: + skip -= 1 + continue + if not quoted and text.startswith('"""', index): + end = text.find('"""', index + 3) + if end < 0: + return "" + skip = end + 2 - index + continue if quoted: if escaped: escaped = False diff --git a/tests/test_localize_changes.py b/tests/test_localize_changes.py index 0c0b117879d2..745ff85300b5 100644 --- a/tests/test_localize_changes.py +++ b/tests/test_localize_changes.py @@ -59,6 +59,20 @@ def test_key_only_call_cannot_steal_next_default(self): self.assertEqual({key: value.source for key, value in messages.items()}, {"other": "Other"}) self.assertTrue(any("cannot safely prepare" in item for item in attention)) + def test_multiline_default_is_read_whole_and_deindented(self): + source = ( + 'let usage = String(localized: "cli.help", defaultValue: """\n' + ' Usage: cmux thing\n' + '\n' + ' --json Output JSON\n' + ' """)\n' + 'let next = String(localized: "cli.next", defaultValue: "Next")' + ) + messages, attention = MODULE.parse_swift_messages("CLI/Thing.swift", source) + self.assertEqual(messages["cli.help"].source, "Usage: cmux thing\n\n --json Output JSON") + self.assertEqual(messages["cli.next"].source, "Next") + self.assertEqual(attention, []) + def test_import_validates_all_catalog_locale_groups_before_writing(self): with tempfile.TemporaryDirectory() as directory: root = Path(directory)