Phase 4, third step, of docs/material-design-conformance.md. `Text("Add chapter to
${uiState.artifact.name}")` becomes a resource holding `Add chapter to %1$s` and a call
passing the expression. 49 call sites. Literals in composables go 76 -> 39;
`stringResource` goes 374 -> 424.
**A silent bug in the previous commit's extractor, found by this one.** Imports were
tested with `statement in source`, and the generated accessors are named after their
strings -- so `import mantra.composeapp.generated.resources.translate` is a *prefix* of
`...resources.translate_into_which_dialect`. The substring test decided the import was
already there, and the compiler reported "Unresolved reference 'translate'" in a file
whose imports looked complete. Both extractors now match whole lines, and the helper
carries the explanation.
**Four filters, each earned by something the dry run got wrong.**
*A template that is only interpolation has nothing to translate.* `Text("$name")` would
have become a resource holding `%1$s` -- longer, slower, and no more localisable than the
code it replaced.
*A leading or trailing space means it is being glued to a neighbour.* " \\u00b7 %1$s" is a
separator. The test has to be on the format string rather than on the literal halves: a
template opening with an interpolation leaves the first part empty and the second starting
with the separating space, which makes "%1$s Key packages" look like a fragment when it is
a whole label.
*`\\uXXXX` and `\\"` are Kotlin syntax, not XML.* Left alone they would have shipped as the
six visible characters of the escape. They are decoded into the resource, which is UTF-8
and can hold `·` directly. `\\n` is **not** decoded, because
StringCatalogueJvmTest shows Compose Resources processes that one and a real newline in an
XML value would be reflowed by the parser.
*A term of a `+` concatenation is still not a string.* Same rule as the plain extractor.
**Three copy problems surfaced only here, because interpolated strings had never been
checked.** `m3-title-case.py` excludes anything containing `$` -- an interpolation is not a
literal -- so `"$count Key Packages"` had been invisible to every pass so far, as had
`"replying To ${…}"`. And a third instance of the old product name, in
`"...once they're on Torch."`. All three fixed. Worth noting as a gap in the checker rather
than a one-off: title case inside a template is still unchecked, and there are 83
concatenation fragments left where it could hide.
**Two new assertions, on the two things a compiler cannot see.** Argument *order* is
decided by where each `${…}` sat, and a transposition compiles and reads plausibly --
"Recovered 3 of 12" against "Recovered 12 of 3" -- so a two-argument and a three-argument
string are asserted end to end. The three-argument one doubles as the check that `·`
was decoded rather than passed through.
**What is deliberately left.** 83 literals that are terms of a `+` concatenation.
Reassembling `"a " + x + " b"` into one format string means deciding what the whole
sentence is, and several are pluralisations -- `(if (n == 2) "event" else "events")` --
which want a real plural resource rather than a format argument, and that is an API choice
rather than a rewrite. `m3-extract-formatted.py --remaining` lists them.
**Tests.** 949 pass, 600 jvm over 73 classes and 349 android over 44, up from 947/598/349.
`:composeApp:compileDebugKotlinAndroid` builds; the debug apk installs and runs on
emulator-5554 through onboarding, the message list and a chat room with its text intact.
`m3-audit.sh --check` exits 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
280 lines
11 KiB
Python
Executable File
280 lines
11 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Move literal UI strings out of composables and into the resource catalogue.
|
|
|
|
Handles the simple case only: a `text = "..."` or `Text("...")` whose literal contains no
|
|
Kotlin interpolation. Interpolated strings need format placeholders and an argument order
|
|
decided per site, which is not something to do blind.
|
|
|
|
Resource names are derived from the string's content, snake_cased and truncated at a word
|
|
boundary. That is the conventional shape for an automated extraction and it has a known
|
|
cost: rewording the copy makes the name a little stale. The alternative -- a name derived
|
|
from the string's *purpose* -- needs somebody to read all 285 call sites, and a wrong
|
|
purpose in a name is worse than a slightly dated one.
|
|
|
|
Usage:
|
|
m3-extract-strings.py dry run, prints the plan
|
|
m3-extract-strings.py --apply
|
|
"""
|
|
import os, re, sys, io, collections
|
|
|
|
UI = 'composeApp/src/commonMain/kotlin/press/mantra/compose/ui'
|
|
STRINGS = 'composeApp/src/commonMain/composeResources/values/strings.xml'
|
|
DRY = '--apply' not in sys.argv
|
|
|
|
# Literals inside a `Text(...)` call, with no `$` inside.
|
|
#
|
|
# The first version of this matched a bare `text = "…"` anywhere, which is wrong: `text`
|
|
# is an ordinary parameter name and the tree uses it on data classes too. It rewrote
|
|
# `NavigationUIState.Loading(text = "…")` -- not a composable -- and the compiler caught
|
|
# it with "@Composable invocations can only happen from the context of a @Composable
|
|
# function". So the call is brace-matched instead, and only literals genuinely inside a
|
|
# `Text(` are touched.
|
|
def text_call_spans(source):
|
|
"""Character ranges of every `Text(` / `BasicText(` call, brace-matched.
|
|
|
|
Anchoring to the call is what keeps `NavigationUIState.Loading(text = "…")` out of
|
|
the results. `text` is an ordinary parameter name and the tree uses it on data
|
|
classes too; an earlier version matched a bare `text = "…"` anywhere and rewrote a
|
|
non-composable, which the compiler caught with "@Composable invocations can only
|
|
happen from the context of a @Composable function".
|
|
"""
|
|
spans = []
|
|
for m in re.finditer(r'\b(?:Basic)?Text\s*\(', source):
|
|
depth, i = 0, m.end() - 1
|
|
while i < len(source):
|
|
if source[i] == '(':
|
|
depth += 1
|
|
elif source[i] == ')':
|
|
depth -= 1
|
|
if depth == 0:
|
|
spans.append((m.start(), i))
|
|
break
|
|
i += 1
|
|
return spans
|
|
|
|
|
|
def kotlin_strings(source):
|
|
"""Every top-level Kotlin string literal, as (start, end, body, interpolated).
|
|
|
|
A regex cannot do this. `"a ${if (n == 1) "chunk" else "chunks"} b"` contains two
|
|
inner literals that belong to the outer template, and matching quote pairs left to
|
|
right pulls them out as strings of their own -- which is how an earlier version of
|
|
this script decided "chunk" and "note" were UI strings worth translating.
|
|
|
|
So the source is scanned: on an opening quote, walk forward tracking `${` depth and
|
|
recursing over nested literals, and stop at the closing quote that is at depth zero.
|
|
Raw strings and escapes are skipped rather than parsed, which is enough here.
|
|
"""
|
|
out, i, n = [], 0, len(source)
|
|
while i < n:
|
|
c = source[i]
|
|
if c == '"' and source[i:i + 3] == '\"\"\"':
|
|
end = source.find('\"\"\"', i + 3)
|
|
i = n if end == -1 else end + 3
|
|
continue
|
|
if c != '"':
|
|
i += 1
|
|
continue
|
|
j, depth, interpolated = i + 1, 0, False
|
|
while j < n:
|
|
ch = source[j]
|
|
if ch == '\\':
|
|
j += 2
|
|
continue
|
|
if ch == '$' and j + 1 < n and (source[j + 1] == '{' or source[j + 1].isalpha()):
|
|
interpolated = True
|
|
if source[j + 1] == '{':
|
|
depth += 1
|
|
j += 2
|
|
continue
|
|
if depth > 0:
|
|
if ch == '{':
|
|
depth += 1
|
|
elif ch == '}':
|
|
depth -= 1
|
|
elif ch == '"':
|
|
# a literal inside the interpolation: skip it whole
|
|
k = j + 1
|
|
while k < n and source[k] != '"':
|
|
k += 2 if source[k] == '\\' else 1
|
|
j = k
|
|
j += 1
|
|
continue
|
|
if ch == '"':
|
|
break
|
|
if ch == '\n':
|
|
break
|
|
j += 1
|
|
if j < n and source[j] == '"':
|
|
out.append((i, j + 1, source[i + 1:j], interpolated))
|
|
i = j + 1
|
|
else:
|
|
i += 1
|
|
return out
|
|
|
|
|
|
def literals_in_text_calls(source):
|
|
"""(start, end, value) for each plain, whole literal inside a Text( call."""
|
|
spans = text_call_spans(source)
|
|
found = []
|
|
for start, end, value, interpolated in kotlin_strings(source):
|
|
if interpolated or not value.strip():
|
|
continue
|
|
if not any(a <= start <= b for a, b in spans):
|
|
continue
|
|
line_start = source.rfind('\n', 0, start) + 1
|
|
line = source[line_start:source.find('\n', start)]
|
|
if line.lstrip().startswith(('//', '*', '/*')):
|
|
continue
|
|
# A fragment of a concatenation. `"a " + x + " b"` is one sentence in three
|
|
# pieces, and " b" is not a translatable unit -- word order differs between
|
|
# languages, so a translator handed " b" has nothing to work with. These go with
|
|
# the interpolated strings, which need format placeholders and a human.
|
|
before = source[:start].rstrip()
|
|
after = source[end:].lstrip()
|
|
if before.endswith('+') or after.startswith('+'):
|
|
continue
|
|
if value != value.strip() or not any(c.isalpha() for c in value):
|
|
continue
|
|
if before.endswith('append('):
|
|
continue
|
|
# The same fragment problem one level out. A pluralisation reads
|
|
#
|
|
# (if (n == 2) "event" else "events") +
|
|
#
|
|
# so neither literal is adjacent to the `+` -- the parenthesis is. Testing the
|
|
# line instead catches those four sites while leaving a genuine either/or alone:
|
|
#
|
|
# text = if (session == null) "Start key ceremony" else "Try again"
|
|
#
|
|
# has no `+` on its line and both branches are whole strings.
|
|
stripped = line.strip()
|
|
if stripped.endswith('+') or stripped.startswith('+'):
|
|
continue
|
|
found.append((start, end, value))
|
|
return found
|
|
|
|
|
|
def resource_name(text, taken):
|
|
slug = re.sub(r'[^a-z0-9]+', '_', text.lower()).strip('_')
|
|
words = slug.split('_')
|
|
name, out = '', []
|
|
for w in words:
|
|
if len(name) + len(w) + 1 > 44:
|
|
break
|
|
out.append(w)
|
|
name = '_'.join(out)
|
|
name = name or 'string'
|
|
if name[0].isdigit():
|
|
name = 's_' + name
|
|
base, i = name, 2
|
|
while name in taken:
|
|
name = f'{base}_{i}'
|
|
i += 1
|
|
return name
|
|
|
|
|
|
def xml_escape(text):
|
|
"""XML entities only.
|
|
|
|
Compose Resources is not aapt, and the difference is a shipped bug rather than a
|
|
detail. An earlier version of this escaped apostrophes as `\\'` and doubled `%`, which
|
|
is what android's resource compiler requires. Compose Resources does neither:
|
|
`getString` returned `Don\\'t sign`, backslash included, and
|
|
StringCatalogueJvmTest caught it.
|
|
|
|
It *does* process `\\n`, which the same test asserts -- so escape handling here is
|
|
partial rather than absent, and worth checking per escape rather than assuming a
|
|
family.
|
|
|
|
`%` is left alone. Nothing in this catalogue contains one, and if something does
|
|
later it only matters for an entry passed through `getString(resource, args)`.
|
|
"""
|
|
return text.replace('&', '&').replace('<', '<').replace('>', '>')
|
|
|
|
|
|
|
|
def has_import(source, statement):
|
|
"""Whole-line match.
|
|
|
|
`statement in source` is wrong here and silently so: the accessors are named after
|
|
their strings, so `...resources.translate` is a prefix of
|
|
`...resources.translate_into_which_dialect`, and a substring test decides the import
|
|
is already present. The compiler then reports "Unresolved reference 'translate'" in a
|
|
file whose imports look complete.
|
|
"""
|
|
return any(line.strip() == statement for line in source.split('\n'))
|
|
|
|
def main():
|
|
occurrences = collections.defaultdict(list)
|
|
for root, _, files in os.walk(UI):
|
|
for f in sorted(files):
|
|
if not f.endswith('.kt'):
|
|
continue
|
|
path = os.path.join(root, f)
|
|
src = open(path, encoding='utf-8').read()
|
|
for start, _end, value in literals_in_text_calls(src):
|
|
occurrences[value].append((path, src[:start].count('\n') + 1))
|
|
|
|
taken, names = set(), {}
|
|
for text in sorted(occurrences):
|
|
names[text] = resource_name(text, taken)
|
|
taken.add(names[text])
|
|
|
|
total = sum(len(v) for v in occurrences.values())
|
|
print(f'{len(occurrences)} distinct strings, {total} occurrences')
|
|
if DRY:
|
|
for text in sorted(occurrences)[:10]:
|
|
print(f' {names[text]:46s} {text[:60]!r}')
|
|
print(' ...')
|
|
return 0
|
|
|
|
# Rewrite the call sites.
|
|
rewritten = 0
|
|
for root, _, files in os.walk(UI):
|
|
for f in sorted(files):
|
|
if not f.endswith('.kt'):
|
|
continue
|
|
path = os.path.join(root, f)
|
|
src = io.open(path, encoding='utf-8').read()
|
|
found = literals_in_text_calls(src)
|
|
changed = len(found)
|
|
# Right to left, so earlier offsets stay valid.
|
|
for start, end, value in reversed(found):
|
|
src = src[:start] + f'stringResource(Res.string.{names[value]})' + src[end:]
|
|
if changed:
|
|
for imp in ('import mantra.composeapp.generated.resources.Res',
|
|
'import org.jetbrains.compose.resources.stringResource'):
|
|
if not has_import(src, imp):
|
|
lines = src.split('\n')
|
|
idx = max(i for i, l in enumerate(lines) if l.startswith('import '))
|
|
lines.insert(idx + 1, imp)
|
|
src = '\n'.join(lines)
|
|
# the generated accessors are one per string, under the same package
|
|
need = sorted({names[t] for t in occurrences
|
|
if any(p == path for p, _ in occurrences[t])})
|
|
lines = src.split('\n')
|
|
idx = max(i for i, l in enumerate(lines) if l.startswith('import '))
|
|
for nm in reversed(need):
|
|
imp = f'import mantra.composeapp.generated.resources.{nm}'
|
|
if not has_import(src, imp):
|
|
lines.insert(idx + 1, imp)
|
|
src = '\n'.join(lines)
|
|
io.open(path, 'w', encoding='utf-8').write(src)
|
|
rewritten += changed
|
|
print(f'{rewritten} call sites rewritten')
|
|
|
|
# Write the catalogue.
|
|
existing = io.open(STRINGS, encoding='utf-8').read()
|
|
entries = '\n'.join(
|
|
f' <string name="{names[t]}">{xml_escape(t)}</string>'
|
|
for t in sorted(occurrences, key=lambda x: names[x]))
|
|
io.open(STRINGS, 'w', encoding='utf-8').write(
|
|
existing.replace('</resources>', entries + '\n</resources>'))
|
|
print(f'{len(occurrences)} entries written to {STRINGS}')
|
|
return 0
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|