#!/usr/bin/env python3 """Check every R.string / R.plurals reference against strings.xml. Three ways a resource reference compiles and then fails, none of which ktlint, detekt or the Kotlin compiler will catch: 1. the name does not exist -> resource-not-found at runtime 2. `stringResource` on a plural (or the reverse) -> wrong overload, wrong text 3. the format string takes more arguments than the call passes -> the format silently renders `%2$s` as literal text, or throws python3 android/tools/check-strings.py Exits non-zero on any problem. """ import glob import os import re import sys import xml.etree.ElementTree as ET BASE = ( sys.argv[1] if len(sys.argv) > 1 else os.path.join(os.path.dirname(os.path.abspath(__file__)), "..") ) STRINGS = os.path.join(BASE, "app", "src", "main", "res", "values", "strings.xml") SOURCES = os.path.join(BASE, "app", "src", "main", "java", "**", "*.kt") CALL = re.compile( r"(stringResource|pluralStringResource)\(\s*R\.(string|plurals)\.(\w+)" r"((?:[^()]|\([^()]*\))*)\)" ) def arity(text): """How many distinct arguments a format string consumes.""" numbered = set(re.findall(r"%(\d)\$", text)) return len(numbered) if numbered else len(re.findall(r"%[sd]", text)) def supplied_args(rest): """Count top-level commas in an argument tail. Kotlin permits a TRAILING comma before the closing paren, which is not an argument — counting it inflated every multi-line call by one the first time this was written, and made three correct call sites look broken. Braces count toward depth as well as parens, or a comma inside a lambda would be read as another argument. """ rest = rest.rstrip() if rest.endswith(","): rest = rest[:-1] depth = 0 count = 0 for ch in rest: if ch in "([{": depth += 1 elif ch in ")]}": depth -= 1 elif ch == "," and depth == 0: count += 1 return count def main(): root = ET.parse(STRINGS).getroot() strings = {e.get("name"): "".join(e.itertext()) for e in root.findall("string")} plurals = { e.get("name"): max( (arity("".join(i.itertext())) for i in e.findall("item")), default=0 ) for e in root.findall("plurals") } problems = 0 for path in glob.glob(SOURCES, recursive=True): with open(path, encoding="utf-8") as fh: src = fh.read() for match in CALL.finditer(src): fn, kind, name, rest = match.groups() line = src[: match.start()].count("\n") + 1 where = f"{os.path.basename(path)}:{line} {name}" if kind == "string" and name not in strings: print(f"MISSING {where}: no such string") problems += 1 continue if kind == "plurals" and name not in plurals: print(f"MISSING {where}: no such plural") problems += 1 continue if (fn == "pluralStringResource") != (kind == "plurals"): print(f"KIND {where}: {fn} used on R.{kind}") problems += 1 continue passed = supplied_args(rest) # A plural call passes the count first, then the format arguments. wanted = arity(strings[name]) if kind == "string" else plurals[name] + 1 if passed != wanted: print(f"ARITY {where}: wants {wanted}, call passes {passed}") problems += 1 print(f"\n{len(strings)} strings, {len(plurals)} plurals, {problems} problems") return 1 if problems else 0 if __name__ == "__main__": sys.exit(main())