Skip to content

Adopt Xcode String Catalog symbol generation across the app#79

Draft
kyleve wants to merge 8 commits into
mainfrom
cursor/string-catalog-codegen
Draft

Adopt Xcode String Catalog symbol generation across the app#79
kyleve wants to merge 8 commits into
mainfrom
cursor/string-catalog-codegen

Conversation

@kyleve

@kyleve kyleve commented Jul 13, 2026

Copy link
Copy Markdown
Owner

What

Switches the whole repo onto Xcode's built-in String Catalog symbol generation and makes every string reference type-safe. The hand-maintained string facades (Strings.swift ~1,730 lines, ShareStrings, WidgetStrings) and all raw String(localized: "key", bundle: .module) lookups are gone; user-facing copy now resolves through generated LocalizedStringResource symbols, so a typo'd or removed key is a compile error instead of a runtime surprise.

Scope is the codegen migration only — no new localization of currently-hardcoded/DEBUG surfaces or Info.plist strings.

How it works

  • STRING_CATALOG_GENERATE_SYMBOLS = YES is set project-wide in Project.swift for the Tuist-native app/extension targets. The SwiftPM library targets generate symbols automatically from the toolchain (verified empirically), so they need no wiring.
  • A manual catalog key some.key generates a .someKey symbol on LocalizedStringResource, referenced directly at call sites: Text(.tabPrimary), String(localized: .commonOk), Label(.evidenceAdd, systemImage:).
  • Generated symbols are internal per target — fine here, because cross-module reuse already flows through public Swift API (e.g. the share extension renders kinds via WhereUI's public EvidenceKind.displayName), never raw keys.

Commits (one per module, bisectable)

  1. Groundwork — enable the build setting; normalize the extension catalogs so every key is manual.
  2. RegionKitRegion.localizedName switches over .regionCalifornia etc.
  3. LifecycleKitLifecycleFailureView uses .failureLaunchTitle / .failureLaunchRetry.
  4. WhereCore — reminder/summary/issue-alert/backup strings; two fragile String(format:) compositions replaced with type-safe symbols (output unchanged).
  5. WhereUI — delete the Strings facade; rewrite ~277 call sites across 30 files. Add WhereFormat for the cases that genuinely need logic (grouping-free years, plurals, enum switches, composed accessibility labels), each composing generated symbols internally. Add the 34 Evidence/common keys that previously only existed via defaultValue:. Replace StringsTests with WhereFormatTests.
  6. WhereShareExtension / WhereWidgets — drop ShareStrings / WidgetStrings for the extensions' own generated symbols.
  7. Docs — update the localization guidance (feature + module AGENTS/READMEs) to the "add a manual key → reference its symbol" workflow.

Verification

  • ./swiftformat --lint . clean (326 files).
  • Full Stuff-iOS-Tests scheme green (all bundles) on iPhone 17 / iOS 26.2.
  • Catalog re-serialization verified lossless: 0 keys dropped, 0 existing values changed, 34 added.
  • All composed/interpolated catalog values confirmed to match the old defaultValue: separators and argument order, and no catalog value contains markdown, so Text(String)Text(.symbol) renders identically.

Reviewer notes

  • The WhereUI catalog diff is large because the file was re-serialized into one fully-sorted, all-manual catalog — it's a re-sort + extractionState normalization, not content edits.
  • Outside Text/Label/Button (verified to accept LocalizedStringResource), call sites use String(localized: .symbol) as a deliberate safety choice; a follow-up could tighten .navigationTitle(...) etc. to bare symbols.
  • WhereFormat's enum-switch/compose helpers were cross-checked by hand against the old mappings; the untested cases are at parity with the previous StringsTests coverage.
Open in Web Open in Cursor 

kyleve added 8 commits July 12, 2026 17:51
Turn on Xcode's type-safe String Catalog codegen across the six catalog-backed
targets, the first step of the codegen migration.

- Project.swift: set STRING_CATALOG_GENERATE_SYMBOLS=YES in the project base
  settings so the Tuist-native app/extension targets generate LocalizedStringResource
  symbols. The SwiftPM package targets (WhereUI, WhereCore, RegionKit, LifecycleKit)
  generate symbols automatically from the toolchain and need no setting.
- Normalize the two Tuist-native catalogs so every key produces a symbol:
  WhereWidgets keys stale -> manual; WhereShareExtension keys gain
  extractionState: manual.

Verified by building RegionKit (SPM) and WhereWidgets (Tuist-native) against a
throwaway generated-symbol reference; both compiled. No behavior change.
Region.localizedName now switches over the generated .region<Case> symbols
(String(localized: .regionCalifornia)) instead of raw "region.<rawValue>" keys
with bundle: .module. The exhaustive switch still makes a new region case a
compile error until its manual catalog key (and thus symbol) exists; update the
README's add-a-region note accordingly.
Replace the String(localized:bundle:.module) title/retry lookups with the
generated .failureLaunchTitle / .failureLaunchRetry symbols passed straight to
Label and Button.
Replace every String(localized: "key", bundle: .module) in the reminder,
daily-summary, issue-alert, and backup paths with the generated
LocalizedStringResource symbols. Plural/interpolated keys become generated
functions (.dataIssuesNotificationBody(count), .summaryNotificationDayCount(days),
.backupErrorUnsupportedFormatVersion(version)).

DailySummaryReconciler.summaryFragment and BackupError.errorDescription drop
their String(format:) composition in favor of the type-safe symbols; the
composed summary body ("3 days in California, 1 day in New York") is unchanged,
verified by WhereCoreTests.
Delete the ~1730-line hand-written Strings enum and reference Xcode's generated
LocalizedStringResource symbols directly at call sites (Text(.tabPrimary),
String(localized: .commonOk), etc.). ~277 call sites across 30 view files.

- Add WhereFormat: the small set of helpers that genuinely need logic — the
  non-catalog formatters (grouping-free year, drift measurement, coordinate),
  count/plural helpers, enum switches (region-map kind, resolution section,
  recent-activity window/reason), and the composed accessibility labels. Each
  composes generated symbols with runtime values, so a removed key is a compile
  error.
- EvidenceKind.displayName now switches over the generated evidence-kind symbols.
- Catalog: mark the 46 auto-extracted keys manual and add the 34 Evidence/common
  keys that only existed via defaultValue: so every referenced key now generates
  a symbol; the file is re-sorted with all keys manual.
- Replace StringsTests with WhereFormatTests: compile-time safety covers key
  existence, so the suite now pins catalog values, plural variations, year
  formatting, EvidenceKind names, and the accessibility-cue composition.

All 198 WhereUITests pass; Where app builds.
Replace the ShareStrings facade with the extension's own generated
LocalizedStringResource symbols in ShareEvidenceView (bare symbols in
Text/Button, String(localized:) elsewhere; the attachment header picks its
plural symbol inline). Delete ShareStrings.swift.
Replace the WidgetStrings facade with the extension's generated symbols for the
widget-gallery name/description on each StaticConfiguration
(String(localized: .widgetGalleryTodayName), etc.). Delete WidgetStrings.swift.
Update the localization guidance to the generated-symbol model: add manual keys
to the catalog and reference the generated LocalizedStringResource symbols
directly (Text(.tabPrimary), String(localized: .commonOk)), with WhereUI's
composition/formatting living in WhereFormat. Drops the Strings.swift /
ShareStrings / WidgetStrings / bundle: .module references from the Where feature
AGENTS.md and the WhereCore / WhereWidgets / WhereShareExtension module docs.
@kyleve kyleve marked this pull request as draft July 13, 2026 01:24
@kyleve

kyleve commented Jul 13, 2026

Copy link
Copy Markdown
Owner Author

Posted by an AI agent on @kyleve's behalf.

CI is red due to an Xcode-toolchain gap, not a defect in this change — recommend holding until the runners have Xcode 27. Marking as draft.

Root cause

Xcode's built-in String Catalog symbol generation for SwiftPM package targets is unreliable on Xcode 26.x. Our RegionKit / LifecycleKit / WhereCore / WhereUI symbols generate fine in the app build graph, but the generated symbols file isn't reliably produced before the target's own sources compile in the test-bundle build graph — so CI fails with:

type 'String.LocalizationValue' has no member 'regionCalifornia'

Reproduced locally under Xcode 26.6 (Swift 6.3.3, = the macos-26 runner). Builds and passes the full Stuff-iOS-Tests scheme cleanly under Xcode 27 (Swift 6.4), where the SwiftPM change that wires catalogs into the Sources phase makes generation deterministic. GitHub's macos-26 image only ships Xcode 26.x — there is no Xcode 27 to pin to yet.

Fixes explored — all dead ends on 26.x

  • Force the setting on the package targetsSTRING_CATALOG_GENERATE_SYMBOLS is ignored for SwiftPM package targets (confirmed: =NO disabled a Tuist-native target but not a package target; CLI + XCODE_XCCONFIG_FILE overrides don't reach them). Can neither reliably enable nor disable it.
  • Tuist XcodeProj integration (.external(name:)) — blocked by our single root-package layout: tuist generate crashes with a duplicate-path error, the root package's products aren't exposed as .external, and SwiftPM won't let a subdirectory manifest reference sources via ../ (would require relocating all module sources).
  • Commit a generated-symbols shim (the clever idea we tried) — Xcode 26.x's built-in generation for packages is unconditional, so a committed shim collides → invalid redeclaration. This duplicate-symbols wall is documented independently: https://elegantchaos.com/2026/02/12/string-catalogues.html

Corroboration that this area is buggy / actively patched on 26.x

Recommendation

The branch is correct and needs zero changes — it's green on Xcode 27. Hold as draft until the CI runners have Xcode 27, at which point this should pass as-is. If we need type-safe strings sooner, the only 26.x-viable path is a custom SwiftPM build-tool-plugin codegen (gated to not run under Xcode) — more moving parts; happy to spike it separately if desired.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant