scripts/check-strings.py
#!/usr/bin/env python3
"""List the string literals in the app's Swift sources that read like interface text, for localization (#350).
A literal counts as interface text when it holds a letter, is not a code word such as `window_focus_west`,
`PANERU_CONFIG`, a path, or a URL, and its line is not a log, assertion, or test call. Comments are skipped.
A literal that reads like text but is not shown to people, such as a file header, carries a
`// Not interface text` comment on its line or the line above. Above a line that opens a list, it covers the list.
Without options the script prints each literal as `path:line: text` and a count per file, and exits 0.
With `--check DIR` it exits 1 when a literal is not localized where it stands. DIR holds the `.stringsdata` files that
the Swift compiler writes with `-emit-localized-strings`: one entry for each string that SwiftUI or `String(localized:)`
looks up, with its line. A literal that only matches a catalog key elsewhere, such as text passed to a `String`
parameter, is not localized. `scripts/sync-strings.sh --check` builds the files and runs this check.
Interpolations such as `\\(count)`, Swift escapes, and placeholders such as `%lld` or `%1$@` compare as equal.
With `--translations CATALOG` it exits 1 when a key lacks a translation in one of LANGUAGES, or when a translation's
placeholders differ from the English text's. A draft marked `needs_review` counts as a translation.
"""
import argparse
import json
import re
import sys
from pathlib import Path
ROOTS = ("Sources/Miri", "Sources/MiriCore")
NOT_INTERFACE = "// Not interface text"
SKIPPED_LINE = re.compile(r"\b(Logger|logger|log\.|print\(|NSLog|fatalError|precondition|assert|#expect|#require)")
CODE_WORD = re.compile(r"^[a-z0-9_.+\-/:]*$|^[A-Z]{1,3}[a-z]+[A-Z]\w*$|^[a-z]+[A-Z][A-Za-z]*$|^[A-Z0-9_]+$|^[\w-]+(/[\w-]+)+$|^<[a-z]+>$")
ESCAPE = re.compile(r"\\(u\{[0-9A-Fa-f]+\}|.)")
NOT_TEXT = re.compile(r"^\(\?|://|^x-apple|^com\.apple|^io\.github|\.(svg|txt|toml|plist|log)$")
PLACEHOLDER = "\u0000"
CATALOG_PLACEHOLDER = re.compile(r"%(\d+\$)?(ll|l|h)?[@dDuUxXoOfFeEgGcCsSaAp]")
# The languages Miri ships, as string catalog codes. Sam chose them on 8 November 2026 (#350).
LANGUAGES = ("en", "zh-Hans", "zh-Hant", "es", "fr", "de", "ja", "ko", "pt-BR", "pt-PT", "it",
"ru", "ar", "hi", "nl", "tr", "pl", "id", "vi", "th", "uk", "sv")
TRANSLATED = ("translated", "needs_review")
def literals(source: str):
"""Yields (line, text) for each string literal outside comments, with each interpolation as PLACEHOLDER."""
i, line, n = 0, 1, len(source)
while i < n:
c = source[i]
if c == "\n":
line += 1
i += 1
elif source.startswith("//", i):
while i < n and source[i] != "\n":
i += 1
elif source.startswith("/*", i):
end = source.find("*/", i + 2)
end = n if end < 0 else end + 2
line += source.count("\n", i, end)
i = end
elif c == "#" and i + 1 < n and source[i + 1] in "\"#":
# Raw strings such as #"..."# hold regular expressions and paths, not interface text.
hashes = len(source[i:]) - len(source[i:].lstrip("#"))
start = source.find('"', i)
close = '"' + "#" * hashes
end = source.find(close, start + 1)
end = n if end < 0 else end + len(close)
line += source.count("\n", i, end)
i = end
elif c == "/" and is_regex_start(source, i):
# A regex literal such as /["']x/ can hold quotes that do not open a string.
i = regex_end(source, i)
elif c == '"':
start_line = line
text, i, line = read_string(source, i, line)
yield start_line, text
else:
i += 1
def is_regex_start(source: str, i: int) -> bool:
"""Whether the slash at `i` opens a regex literal: it follows an opening bracket, a comma, or `=`."""
before = source[:i].rstrip(" ")
after = source[i + 1:i + 2]
return bool(before) and before[-1] in "(,=[" and after not in ("", " ", "/", "*", "\n")
def regex_end(source: str, i: int) -> int:
"""Returns the index after the regex literal that opens at `i`, or the end of its line."""
j = i + 1
while j < len(source) and source[j] != "\n":
if source[j] == "\\":
j += 2
continue
if source[j] == "/":
return j + 1
j += 1
return j
def read_string(source: str, i: int, line: int) -> tuple[str, int, int]:
"""Reads the literal that opens at `i` and returns its text, the index after it, and the line it ends on."""
multiline = source.startswith('"""', i)
quote = '"""' if multiline else '"'
i += len(quote)
parts = []
n = len(source)
while i < n and not source.startswith(quote, i):
c = source[i]
if c == "\n":
line += 1
parts.append(" ")
i += 1
elif c == "\\" and i + 1 < n and source[i + 1] == "(":
depth, i = 1, i + 2
while i < n and depth:
if source[i] == '"':
_, i, line = read_string(source, i, line)
continue
if source[i] == "\n":
line += 1
depth += {"(": 1, ")": -1}.get(source[i], 0)
i += 1
parts.append(PLACEHOLDER)
elif c == "\\" and i + 1 < n:
parts.append(source[i:i + 2])
i += 2
else:
parts.append(c)
i += 1
return squeeze("".join(parts)), i + len(quote), line
def squeeze(text: str) -> str:
"""Joins the lines of a multiline literal, so it compares with its catalog key whatever its indentation."""
return " ".join(text.split())
def decode(text: str) -> str:
"""Turns Swift escapes such as \\" and \\u{201C} into their characters, as the catalog key holds them."""
simple = {"n": "\n", "t": "\t", "r": "\r", "0": "\0", '"': '"', "'": "'", "\\": "\\"}
def character(match: re.Match) -> str:
body = match.group(1)
return chr(int(body[2:-1], 16)) if body.startswith("u{") else simple.get(body, body)
return squeeze(ESCAPE.sub(character, text))
def is_interface_text(text: str) -> bool:
words = ESCAPE.sub(" ", text.replace(PLACEHOLDER, "")).strip()
return bool(re.search(r"[A-Za-z]", words)) and not CODE_WORD.match(words) and not NOT_TEXT.search(words)
def marked_lines(lines: list[str]) -> set[int]:
"""The line numbers that a `// Not interface text` comment covers: its own line and the next one.
When that next line opens a bracket that it does not close, such as a list of app names, the whole list is covered."""
covered = set()
for index, line in enumerate(lines):
if NOT_INTERFACE not in line:
continue
number = index + 1
covered.update({number, number + 1})
depth = 0
for following in range(index + 1, len(lines)):
code = lines[following].split("//")[0]
depth += code.count("[") + code.count("(") - code.count("]") - code.count(")")
covered.add(following + 1)
if depth <= 0:
break
return covered
def candidates(root: Path):
"""Yields (relative path, line, text) for each literal that reads like interface text."""
for folder in ROOTS:
for path in sorted((root / folder).glob("**/*.swift")):
relative = path.relative_to(root).as_posix()
source = path.read_text(encoding="utf-8")
lines = source.splitlines()
skipped = marked_lines(lines)
for number, text in literals(source):
if is_interface_text(text) and not SKIPPED_LINE.search(lines[number - 1]) and number not in skipped:
yield relative, number, text
def localized_lines(folder: Path, root: Path) -> set[tuple[str, int, str]]:
"""Reads (relative path, line, key) for each string the compiler extracted, from the .stringsdata files in `folder`."""
found = set()
for path in folder.glob("*.stringsdata"):
data = json.loads(path.read_text(encoding="utf-8"))
source = Path(data["source"]).resolve()
if not source.is_relative_to(root.resolve()):
continue
relative = source.relative_to(root.resolve()).as_posix()
for entries in data.get("tables", {}).values():
for entry in entries:
key = squeeze(CATALOG_PLACEHOLDER.sub(PLACEHOLDER, entry["key"]))
found.add((relative, entry["location"]["startingLine"], key))
return found
def placeholders(text: str) -> list[tuple[int, str]]:
"""The placeholders in catalog text as sorted (position, type) pairs, so `%@ %lld` equals `%2$lld %1$@`."""
found = []
for number, match in enumerate(CATALOG_PLACEHOLDER.finditer(text.replace("%%", "")), start=1):
position = int(match.group(1)[:-1]) if match.group(1) else number
found.append((position, (match.group(2) or "") + match.group(0)[-1]))
return sorted(found)
def forms(localization: dict) -> dict[str, dict]:
"""The string units of one language: one for plain text, or one per plural form."""
if "stringUnit" in localization:
return {"": localization["stringUnit"]}
plural = localization.get("variations", {}).get("plural", {})
return {name: variation.get("stringUnit", {}) for name, variation in plural.items()}
def translation_problems(catalog: dict) -> list[str]:
"""Each missing or broken translation in the catalog, as `language: problem: key`."""
problems = []
for key, entry in sorted(catalog["strings"].items()):
if entry.get("shouldTranslate") is False:
continue
localizations = entry.get("localizations", {})
english = forms(localizations["en"]) if "en" in localizations else {"": {"value": key}}
expected = placeholders(english.get("", english.get("other", {})).get("value", key))
for language in LANGUAGES[1:]:
units = forms(localizations.get(language, {}))
if not units:
problems.append(f"{language}: missing: {key}")
continue
if "" not in units and "other" not in units:
problems.append(f"{language}: plural form other missing: {key}")
for name, unit in units.items():
value = unit.get("value", "")
if unit.get("state") not in TRANSLATED or not value.strip():
problems.append(f"{language}: missing: {key}")
elif placeholders(value) != expected and not (
name in ("zero", "one", "two") and set(placeholders(value)) <= set(expected)):
problems.append(f"{language}: placeholders differ from the English text: {key}")
extra = {language for entry in catalog["strings"].values() for language in entry.get("localizations", {})}
problems += [f"{language}: not in LANGUAGES" for language in sorted(extra - set(LANGUAGES))]
return problems
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("root", nargs="?", default=Path(__file__).resolve().parents[1], type=Path)
parser.add_argument("--check", metavar="STRINGSDATA", type=Path,
help="fail on a literal that the compiler did not localize, from this .stringsdata folder")
parser.add_argument("--translations", metavar="CATALOG", type=Path,
help="fail on a key that lacks a translation in one of LANGUAGES, in this string catalog")
arguments = parser.parse_args()
if arguments.translations:
problems = translation_problems(json.loads(arguments.translations.read_text(encoding="utf-8")))
for problem in problems:
print(problem)
print(f"{len(problems)} translations missing or broken in {len(LANGUAGES)} languages", file=sys.stderr)
return 1 if problems else 0
localized = localized_lines(arguments.check, arguments.root) if arguments.check else set()
found = [(path, line, text) for path, line, text in candidates(arguments.root)
if not arguments.check or (path, line, decode(text)) not in localized]
counts: dict[str, int] = {}
for path, number, text in found:
print(f"{path}:{number}: {text.replace(PLACEHOLDER, '%@')}")
counts[path] = counts.get(path, 0) + 1
label = "not localized where they stand" if arguments.check else "that read like interface text"
print(f"{len(found)} literals {label}", file=sys.stderr)
for path, count in sorted(counts.items(), key=lambda item: -item[1]):
print(f" {count:4} {path}", file=sys.stderr)
return 1 if arguments.check and found else 0
if __name__ == "__main__":
sys.exit(main())