A fast, accurate SwiftUI and UIKit string scanner that automates the first step of every localization workflow — finding the strings.
SwiftL10n statically analyses your Swift source using SwiftSyntax and detects every hardcoded string that should be localized, and every asset name that does not exist in any .xcassets catalog. It runs entirely at the source level — no simulator, no SourceKit, no runtime.
Feed it a directory; get back a typed list of detected strings with confidence scores, missing-asset diagnostics, and generated i18n.swift / Assets.swift scaffolds ready to drop into your project.
SwiftL10n is source-aware infrastructure validation and generation tooling. It reads your Swift source, cross-references it against your project's infrastructure, and emits additive, deterministic output. It does not rewrite source, does not speculate, and does not mutate files outside of an explicit swiftl10n scan invocation.
Seven principles govern every design decision:
1. Source-driven analysis. The Swift source file is always the input. SwiftL10n reads your code and validates it against infrastructure (catalogs, string tables). Infrastructure generates typed accessors as a byproduct of the same analysis — never as the primary goal.
2. Validation first, generation second. The primary value is catching errors before they reach production: Image("missing_icon") that crashes at runtime, a localization key present in source but absent from the string catalog. Code generation is additive output derived from the same analysis pass.
3. Additive generation only. Generated files (i18n.swift, Assets.swift) supplement your project. They are never required for compilation, never modify existing source, and can be regenerated identically at any time. Deleting them has no effect on your codebase.
4. Deterministic and explainable. The same inputs always produce the same outputs on every machine, in every environment, in every CI run. Every diagnostic has a documented, traceable reason — an enumerated case in FalsePositiveFilter, a concrete delta in ConfidenceScorer. There are no black-box classifiers.
5. Trust through precision. One false positive costs more developer trust than ten missed detections. SwiftL10n stays silent when uncertain. The FalsePositiveFilter is conservative by design. Confidence thresholds are configurable. Diagnostics are suppressible. Trust is earned by being right, not by being loud.
6. CI-safe by default. No file is written during a build. No source is modified without an explicit command. --format json and --fail-on make SwiftL10n composable with any CI pipeline without side effects.
7. Separation of concerns. Localization infrastructure (i18n.swift) and asset infrastructure (Assets.swift) are generated through separate, independent pipelines. i18n.Settings.title() resolves through the NSBundle translation layer at runtime. Assets.profileIcon() loads a pixel buffer from the asset catalog. These operations have different semantics, different failure modes, and must never share a generated namespace.
- 15 detection rules out of the box — SwiftUI and UIKit detected automatically, no configuration needed
- Smart false-positive prevention — SF Symbol names, URLs, file paths, reverse-DNS keys,
snake_case,camelCase, andSCREAMING_CASEidentifiers are all filtered out - Confidence scoring — every result carries a deterministic
0.0–1.0score adjusted for string content and enclosing SwiftUI context - Interpolation awareness —
Text("Hello \(name)!")is detected, templated as"Hello {…}!", and flagged with a warning; it is skipped during code generation - Enclosing context — each string records the surrounding type, property, and function
- Namespace inference — derives logical namespaces from file names (
SettingsView.swift→Settings) - Code generation — emits a type-safe
enum i18n { enum Settings { … } }scaffold usingString(localized:table:bundle:)(iOS 16+ / macOS 13+) - Common string extraction — strings shared across multiple files are automatically lifted into
i18n.Common - Asset catalog parsing — recursively walks every
.xcassetsbundle, respectsprovides-namespacegroups, extracts all named image and color assets - Missing asset diagnostics —
Image("name")wherenameis absent from the catalog produces a.warningbefore any runtime crash; neither SwiftGen nor R.swift do this - Typed
Assets.swiftgeneration —AssetCodeGeneratorconverts the parsed catalog into a type-safeenum Assets { }with namespace-aware nested enums (Assets.Icons.profileIcon()) - Project config file —
.swiftl10n.ymlat the project root; runswiftl10n initto create one - Incremental scanning — SHA-256 per-file hashing skips unchanged files on subsequent runs (
incremental: truein config) - JSON output —
--format jsonproduces a structured, versioned schema for CI artefacts and downstream tooling - Extensible — add custom detection rules by conforming to
DetectionRule - Swift 6 ready — strict concurrency enforced, fully
Sendable, zero data races
Add the package, add one line to your ContentView, change one path, run.
Xcode: File → Add Package Dependencies → paste the URL:
https://github.com/GRimAce11/SwiftL10n.git
Xcode shows two products. Only add SwiftL10nCore:
| Product | What it is | Add to app? |
|---|---|---|
SwiftL10nCore |
The library — scanning, validation, and generation API | Yes ✓ |
swiftl10n |
A CLI terminal tool — not a library | No ✗ |
Package.swift:
dependencies: [
.package(url: "https://github.com/GRimAce11/SwiftL10n.git", from: "0.6.2"),
],
targets: [
.target(name: "YourApp", dependencies: [
.product(name: "SwiftL10nCore", package: "SwiftL10n"),
]),
]import SwiftUI
import SwiftL10nCore
struct ContentView: View {
var body: some View {
Text("Hello, World!")
.task {
#if DEBUG
let projectPath = "/Users/you/Developer/YourApp/Sources/YourApp"
try? await SwiftL10n.scan(projectPath: projectPath)
#endif
}
}
}SwiftL10n.scan() does everything in one call:
- Scans all
.swiftfiles for localizable strings - Validates every
Image("…")/UIImage(named:)reference against your.xcassetscatalogs - Writes
Generated/i18n.swift— typed localization API - Writes
Generated/Assets.swift— typed asset API
Run the app once on Simulator or macOS. The Xcode console prints every detected string, every missing asset warning, then confirms both files were written:
── SettingsView.swift (5 string(s))
[navigationTitle] "Settings" 99%
[Button] "Save" 97%
[Button] "Delete Account" 97%
[alert] "Are you sure?" 96%
[Toggle] "Push Notifications" 95%
── HomeView.swift (3 string(s))
[Text] "Welcome Back" 99%
[Button] "Get Started" 97%
[Button] "Save" 97%
── Asset validation (Assets.xcassets · 32 asset(s))
✓ All asset references resolved
── Common strings (shared across multiple files → i18n.Common)
"Save" ← Home, Profile, Settings
✓ Created → .../Sources/YourApp/Generated/i18n.swift
✓ 10 string(s) · 3 namespace(s) · 0 warning(s) · 0 missing asset(s)
── Asset generation (Assets.xcassets)
24 image(s) · 8 color(s)
✓ Created → .../Sources/YourApp/Generated/Assets.swift
Sandbox error? If you see "You don't have permission to save the file…":
- Xcode → Your Target → Signing & Capabilities → App Sandbox → untick Enable App Sandbox, or
- Keep the sandbox and add File Access → User Selected Files → Read/Write
The
#if DEBUGguard ensures this code never runs in a release build.
Remove the SwiftL10n.scan() call after both files are generated. You only need it when regenerating.
Your project layout after running:
YourApp/
├── YourApp.xcodeproj
└── Sources/
└── YourApp/
├── ContentView.swift
├── HomeView.swift
├── SettingsView.swift
└── Generated/
├── i18n.swift ← localization API, created automatically
└── Assets.swift ← asset API, created automatically
Drag both files into your Xcode Project Navigator and tick your app target — done.
Strings shared across multiple files are automatically lifted into i18n.Common — generated once, usable everywhere.
// Auto-generated by SwiftL10n — do not edit.
// swiftlint:disable all
import Foundation
enum i18n {
fileprivate enum General {
static let table = "Localizable"
static let bundle = Bundle.main
}
}
// "Save" appeared in Settings, Home, and Profile — generated here once
extension i18n {
enum Common {
/// "Save"
static func save() -> String {
String(
localized: "Save",
table: General.table,
bundle: General.bundle,
comment: "Common: Button — Save"
)
}
}
}
extension i18n {
enum Settings {
/// "Settings"
static func settingsNavigationTitle() -> String {
String(
localized: "Settings",
table: General.table,
bundle: General.bundle,
comment: "Settings: navigationTitle — Settings"
)
}
/// "Delete Account"
static func deleteAccountButtonTitle() -> String {
String(
localized: "Delete Account",
table: General.table,
bundle: General.bundle,
comment: "Settings: Button — Delete Account"
)
}
}
}// Before
Text("Welcome Back")
Button("Delete Account") { ... }
.navigationTitle("Settings")
Toggle("Push Notifications", isOn: $on)
.alert("Are you sure?", isPresented: $show) { ... }
// After — type i18n. and Xcode autocompletes the namespace
Text(i18n.Home.welcomeBack())
Button(i18n.Settings.deleteAccountButtonTitle()) { ... }
.navigationTitle(i18n.Settings.settingsNavigationTitle())
Toggle(i18n.Settings.pushNotificationsToggleLabel(), isOn: $on)
.alert(i18n.Settings.areYouSureAlertTitle(), isPresented: $show) { ... }SwiftL10n.scan() generates Assets.swift alongside i18n.swift — nothing extra to configure. Every asset in every .xcassets catalog near your project path gets a typed accessor:
// Auto-generated by SwiftL10n — do not edit.
// swiftlint:disable all
import SwiftUI
public enum Assets {
// MARK: - Images
/// Asset: "logo"
public static func logo() -> Image { Image("logo") }
}
extension Assets {
public enum Icons {
// MARK: - Images
/// Asset: "Icons/profile_icon"
public static func profileIcon() -> Image { Image("Icons/profile_icon") }
}
}
extension Assets {
public enum Theme {
// MARK: - Colors
/// Asset: "Theme/PrimaryBlue"
public static func primaryBlue() -> Color { Color("Theme/PrimaryBlue") }
}
}Replace string literals with typed calls:
// Before
Image("profile_icon")
Color("PrimaryBlue")
// After — type Assets. and Xcode autocompletes the namespace
Assets.Icons.profileIcon()
Assets.Theme.primaryBlue()Drag Generated/Assets.swift into your Xcode Project Navigator and tick your app target. Generation is from the catalog, not from source: every asset in the catalog gets an accessor, regardless of whether your code has referenced it yet.
Namespace-aware: if an asset group in Xcode has Provides Namespace enabled, the generated path mirrors it —
Assets.Icons.profileIcon()forIcons/profile_icon. Groups without the setting are transparent.
Missing assets: if
Image("profile_icon")appears in your source butprofile_icon.imagesetis absent from every catalog,swiftl10n scanemits a warning before any runtime crash occurs. This diagnostic is produced by the source scan, independently of code generation.
String(localized:) reads translations from a String Catalog (.xcstrings) — the modern replacement for .strings files introduced in Xcode 15.
- File → New File → String Catalog — name it exactly
Localizable - Xcode auto-discovers every
String(localized:)call in your source and populates the catalog — no manual entry needed - Add a language: Project → Info → Localizations → + → select a language
- The new language column appears in
Localizable.xcstrings— translate each entry there
String(localized:) falls back to the base English string at runtime until a translation exists, so it is safe to ship with an empty catalog.
UIKit projects use different entry points but the same SwiftL10n.scan() call. No extra configuration — the scanner detects SwiftUI and UIKit strings automatically.
| Pattern | Context |
|---|---|
label.text = "…" / textView.text = "…" |
.uiLabel |
textField.placeholder = "…" / searchBar.placeholder = "…" |
.uiTextFieldPlaceholder |
navigationItem.title = "…" / self.title = "…" / title = "…" |
.uiNavigationTitle |
button.setTitle("…", for: .normal) |
.uiButtonTitle |
UIAlertController(title: "…", message: "…", …) |
.uiAlertTitle + .uiAlertMessage |
UIAlertAction(title: "…", …) |
.uiAlertAction |
UIBarButtonItem(title: "…", …) |
.uiButtonTitle |
UITabBarItem(title: "…", …) |
.uiTabBarItem |
import UIKit
import SwiftL10nCore
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
#if DEBUG
let projectPath = "/Users/you/Developer/YourApp/YourApp" // ← change only this
Task { try? await SwiftL10n.scan(projectPath: projectPath) }
#endif
return true
}
}class SceneDelegate: UIResponder, UIWindowSceneDelegate {
func scene(
_ scene: UIScene,
willConnectTo session: UISceneSession,
options connectionOptions: UIScene.ConnectionOptions
) {
#if DEBUG
let projectPath = "/Users/you/Developer/YourApp/YourApp" // ← change only this
Task { try? await SwiftL10n.scan(projectPath: projectPath) }
#endif
}
}class RootViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
#if DEBUG
let projectPath = "/Users/you/Developer/YourApp/YourApp" // ← change only this
Task { try? await SwiftL10n.scan(projectPath: projectPath) }
#endif
}
}Remove the
SwiftL10n.scan()call after your generated files are in Xcode. You only need it when regenerating.Sandbox error on iOS? App Sandbox is macOS-only — iOS has no toggle. Run on Simulator (not a real device) and the write will succeed without any settings change.
Install once, run from the project root:
git clone https://github.com/GRimAce11/SwiftL10n.git
cd SwiftL10n
swift build -c release
cp .build/release/swiftl10n /usr/local/bin/swiftl10n initWrites a commented .swiftl10n.yml to the current directory:
# SwiftL10n configuration — https://github.com/GRimAce11/SwiftL10n
sources:
- Sources
output:
path: Sources/Generated/i18n.swift
enum_name: i18n
table_name: Localizable
minimum_confidence: 0.85
exclude: []
incremental: false
assets:
enabled: false
path: Sources/Generated/Assets.swift
enum_name: AssetsOptions:
swiftl10n init --sources Sources/App --output App/Generated/i18n.swift
swiftl10n init --min-confidence 0.9
swiftl10n init --force # overwrite existing .swiftl10n.ymlWith a config file present, run with no arguments:
swiftl10n scanOr pass a path directly (overrides sources in config):
swiftl10n scan Sources/Common flags:
| Flag | Description |
|---|---|
--output <path> |
Write generated i18n.swift to this path (overrides config) |
--assets-output <path> |
Generate Assets.swift from .xcassets catalogs found in the project root |
--min-confidence <0–1> |
Ignore strings below this score (overrides config) |
--verbose |
Print every detected string with location and context |
--quiet |
Suppress all output except errors |
--config <path> |
Load config from a specific file instead of auto-discovering |
--format json |
Output structured JSON to stdout instead of console text |
--fail-on warnings |
Exit non-zero on any warning (default: errors only) |
--fail-on never |
Always exit 0 (useful for advisory-only CI steps) |
Examples:
# Scan and generate, verbose
swiftl10n scan --verbose --output Sources/Generated/i18n.swift
# CI: fail the build if any warnings are found
swiftl10n scan --fail-on warnings
# Structured JSON output — pipe to jq or save as artefact
swiftl10n scan --format json | jq '.scanned'
swiftl10n scan --format json > scan-results.jsonJSON output schema:
{
"schema_version": "1",
"swiftl10n_version": "0.6.2",
"scanned": {
"files": 5,
"strings": 42,
"namespaces": 3,
"warnings": 1,
"errors": 0,
"cache_hits": 4
},
"diagnostics": [
{
"code": "SL001",
"severity": "warning",
"message": "Interpolated string in localizable context — no API will be generated: \"Hello {…}!\"",
"file": "HomeView.swift",
"line": 14
}
],
"namespaces": [
{
"name": "Settings",
"source_file": "SettingsView.swift",
"strings": [
{
"value": "Delete Account",
"context": "Button",
"confidence": 0.97,
"has_interpolation": false,
"file": "SettingsView.swift",
"line": 22
}
]
}
]
}When swiftl10n scan is run, it looks for .swiftl10n.yml by walking up from the current directory. It stops searching when it reaches a project root (Package.swift, .git, .xcworkspace) or your home directory.
All fields are optional — any omitted field uses its default.
# Directories to scan, relative to this file.
sources:
- Sources
# Generated output.
output:
path: Sources/Generated/i18n.swift
enum_name: i18n # root enum name in the generated file
table_name: Localizable # .strings table passed to String(localized:)
# Strings below this score are ignored (0.0–1.0).
minimum_confidence: 0.85
# Paths or glob patterns to exclude.
# Supports: exact paths, *.ext, **/pattern
exclude:
- Sources/Generated
- "**/*.generated.swift"
- "**/*.mock.swift"
# Skip re-scanning files whose content hasn't changed.
# Cache is stored at .build/swiftl10n-cache.json.
incremental: true
# Asset code generation.
# Set enabled: true to generate Assets.swift during swiftl10n scan.
assets:
enabled: false
path: Sources/Generated/Assets.swift
enum_name: Assets # root enum name in the generated fileCLI flags always override config values. For example, swiftl10n scan --min-confidence 0.95 ignores minimum_confidence in the config for that run.
When incremental: true is set, swiftl10n scan computes a SHA-256 hash of each file before scanning. If the hash matches the cached value, the file is skipped entirely — no SwiftSyntax parse, no AST walk.
Found 42 string(s) across 8 namespace(s) in 23 file(s) (22 cached).
The cache is stored at .build/swiftl10n-cache.json (already gitignored in SPM projects). It is invalidated automatically when a file changes or when the library version bumps.
import SwiftL10nCore
// Minimal — both files written to projectPath/Generated/
try await SwiftL10n.scan(projectPath: "/path/to/Sources/YourApp")
// With options
let result = try await SwiftL10n.scan(
projectPath: "/path/to/Sources/YourApp",
stringsOutput: "/path/to/Sources/YourApp/Generated/i18n.swift",
assetsOutput: "/path/to/Sources/YourApp/Generated/Assets.swift",
minimumConfidence: 0.9,
generateAssets: true
)
print("\(result.strings.stringCount) string(s) · \(result.assets?.imageCount ?? 0) image(s)")import SwiftL10nCore
let scanner = StringScanner()
let result = scanner.scan(source: sourceCode, filePath: "SettingsView.swift")
for string in result.detectedStrings {
print("""
"\(string.value)"
context: \(string.context.displayName)
confidence: \(String(format: "%.2f", string.confidence))
location: \(string.location.file):\(string.location.line)
type: \(string.enclosingContext.typeName ?? "—")
""")
}let result = try scanner.scan(filePath: "/path/to/SettingsView.swift")for diagnostic in result.diagnostics {
print("[\(diagnostic.severity)] \(diagnostic.message)")
}
let warnings = result.diagnostics.filter { $0.severity == .warning }
let interpolated = result.detectedStrings.filter(\.hasInterpolation)ScanPipeline is the same engine swiftl10n scan uses internally:
import SwiftL10nCore
let config = SwiftL10nConfig(sources: ["Sources"], minimumConfidence: 0.85)
let pipeline = ScanPipeline(config: config, baseURL: projectRootURL)
let result = try pipeline.run()
print("\(result.totalStrings) strings in \(result.namespaces.count) namespace(s)")
print("\(result.cacheHits) file(s) served from cache")
let code = CodeGenerator().generate(namespaces: result.namespaces)If you need to regenerate Assets.swift without a full localization scan — for example after adding new assets — call generateAssets() directly:
import SwiftL10nCore
let result = try await generateAssets(
sourcesPath: "/path/to/Sources/YourApp",
outputPath: "/path/to/Sources/YourApp/Generated/Assets.swift"
)
print("\(result.imageCount) image(s) · \(result.colorCount) color(s) from \(result.catalogCount) catalog(s)")import SwiftL10nCore
// 1. Parse all .xcassets bundles under the project root
let catalog = try AssetCatalogParser.parseCatalogs(in: projectRootURL)
print("\(catalog.imageNames.count) image(s), \(catalog.colorNames.count) color(s)")
// 2. Validate source references against the catalog
let assetScanner = AssetScanner()
let refs = try assetScanner.scan(filePath: "HomeView.swift")
let missing = assetScanner.validate(refs, against: catalog)
// missing → [Diagnostic] with .warning for every Image("name") not found in catalog
// 3. Generate Assets.swift from the catalog
let code = AssetCodeGenerator().generate(catalog: catalog)
// write code to Sources/Generated/Assets.swift
// 4. Custom root enum name
let config = AssetCodeGenerator.Configuration(rootEnumName: "R", accessLevel: "internal")
let customCode = AssetCodeGenerator(configuration: config).generate(catalog: catalog)import SwiftL10nCore
let inferrer = NamespaceInferrer()
let namespaces = inferrer.infer(from: [("SettingsView.swift", result.detectedStrings)])
let output = CodeGenerator().generate(namespaces: namespaces)
print(output)Output:
extension i18n {
enum Settings {
/// "Delete Account"
static func deleteAccountButtonTitle() -> String {
String(
localized: "Delete Account",
table: General.table,
bundle: General.bundle,
comment: "Settings: Button — Delete Account"
)
}
}
}| Call site | DetectionContext |
Example |
|---|---|---|
Text("…") |
.textView |
Text("Hello, World!") |
Button("…") {} |
.buttonLabel |
Button("Delete") {} |
Label("…", systemImage:) |
.labelView |
Label("Settings", systemImage: "gear") |
Toggle("…", isOn:) |
.toggle |
Toggle("Dark Mode", isOn: $enabled) |
TextField("…", text:) |
.textField |
TextField("Email", text: $email) |
.navigationTitle("…") |
.navigationTitle |
.navigationTitle("Home") |
.navigationBarTitle("…") |
.navigationTitle |
.navigationBarTitle("Profile") |
.alert("…", isPresented:) |
.alert |
.alert("Are you sure?", isPresented: $shown) {} |
.confirmationDialog("…", isPresented:) |
.confirmationDialog |
.confirmationDialog("Choose", isPresented: $shown) {} |
.accessibilityLabel("…") |
.accessibilityLabel |
.accessibilityLabel("Close button") |
| Call site / assignment | DetectionContext |
Example |
|---|---|---|
label.text = "…" |
.uiLabel |
nameLabel.text = "Full Name" |
textView.text = "…" |
.uiLabel |
bodyView.text = "Description" |
textField.placeholder = "…" |
.uiTextFieldPlaceholder |
emailField.placeholder = "Email" |
searchBar.placeholder = "…" |
.uiTextFieldPlaceholder |
searchBar.placeholder = "Search" |
navigationItem.title = "…" |
.uiNavigationTitle |
navigationItem.title = "Settings" |
self.title = "…" / title = "…" |
.uiNavigationTitle |
title = "Profile" |
button.setTitle("…", for:) |
.uiButtonTitle |
btn.setTitle("Tap Me", for: .normal) |
UIBarButtonItem(title: "…", …) |
.uiButtonTitle |
UIBarButtonItem(title: "Done", …) |
UIAlertController(title: "…", …) |
.uiAlertTitle |
title argument only |
UIAlertController(…, message: "…", …) |
.uiAlertMessage |
message argument only |
UIAlertAction(title: "…", …) |
.uiAlertAction |
UIAlertAction(title: "Delete", …) |
UITabBarItem(title: "…", …) |
.uiTabBarItem |
UITabBarItem(title: "Home", …) |
| Pattern | Example | Reason |
|---|---|---|
Text(verbatim:) |
Text(verbatim: "debug dump") |
Explicit opt-out |
| URLs | "https://example.com" |
Not UI text |
| File paths | "/Users/dev/file.txt" |
Not UI text |
| SF Symbol names | "person.crop.circle" |
Dot-separated lowercase identifier |
| Reverse-DNS keys | "com.example.app" |
Analytics / bundle ID |
snake_case |
"auth_token" |
Programmatic key |
camelCase |
"viewModelIdentifier" |
Programmatic key |
SCREAMING_CASE |
"FEATURE_FLAG" |
Compile-time constant |
| Interpolated strings | "Hello \(name)!" |
Detected but flagged; skipped in codegen |
Conform to DetectionRule and pass a custom RuleEngine to the scanner:
import SwiftL10nCore
import SwiftSyntax
struct SheetTitleRule: DetectionRule {
let name = "SheetTitleRule"
let baseConfidence = 0.90
let stringArgumentSelector = ArgumentSelector.firstUnlabeled
func match(in node: FunctionCallExprSyntax) -> DetectionContext? {
guard node.calledExpression
.as(MemberAccessExprSyntax.self)?
.declName.baseName.text == "sheet"
else { return nil }
return .unknownUIContext(callee: "sheet")
}
}
// Add your rule on top of all built-in SwiftUI + UIKit rules
let engine = RuleEngine(
rules: RuleEngine.default.rules + [SheetTitleRule()],
assignmentRules: RuleEngine.default.assignmentRules
)
let scanner = StringScanner(ruleEngine: engine)SwiftL10n/
├── Sources/
│ ├── swiftl10n/ # CLI executable
│ │ ├── EntryPoint.swift
│ │ ├── Commands/
│ │ │ ├── ScanCommand.swift # Thin adapter over ScanPipeline
│ │ │ └── InitCommand.swift # swiftl10n init
│ │ ├── Config/
│ │ │ └── ConfigLoader.swift # YAML discovery + parsing (Yams)
│ │ └── Output/
│ │ └── JSONReporter.swift # --format json serialiser
│ └── SwiftL10nCore/ # Library target
│ ├── Scanner/
│ │ ├── StringScanner.swift # Entry point — parses + runs pipeline
│ │ ├── DetectionRule.swift # Protocol + RuleEngine
│ │ ├── Rules/BuiltInRules.swift # SwiftUI rules
│ │ ├── Rules/UIKitRules.swift # UIKit rules
│ │ ├── FalsePositiveFilter.swift
│ │ ├── ConfidenceScorer.swift
│ │ └── ContextExtractor.swift
│ ├── Models/
│ │ ├── DetectedString.swift # Sendable + Codable
│ │ ├── DetectionContext.swift # Sendable + Codable (custom)
│ │ ├── EnclosingContext.swift # Sendable + Codable
│ │ ├── Namespace.swift
│ │ └── Diagnostic.swift # Sendable + Codable
│ ├── Assets/
│ │ ├── AssetCatalog.swift # AssetCatalog model + AssetCatalogParser
│ │ ├── AssetScanner.swift # Source-side detection (Image/Color/UIImage/UIColor)
│ │ └── AssetCodeGenerator.swift # Generates Assets.swift from catalog
│ ├── Config/
│ │ └── SwiftL10nConfig.swift # Project config model (includes AssetsOutputConfig)
│ ├── Pipeline/
│ │ ├── ScanPipeline.swift # Reusable scan orchestration
│ │ ├── GlobMatcher.swift # *, **, ? pattern matching
│ │ └── IncrementalScanCache.swift # CryptoKit SHA-256 cache
│ ├── NamespaceInferrer/
│ ├── CodeGen/
│ │ └── CodeGenerator.swift # Generates i18n.swift from detected strings
│ ├── Common/
│ │ └── CommonStringExtractor.swift
│ └── Diagnostics/
│ └── DiagnosticsEngine.swift
└── Tests/
└── SwiftL10nCoreTests/ # 288 tests across 19 files
For every FunctionCallExprSyntax and SequenceExprSyntax (property assignments) in the syntax tree:
- Rule matching — rules are tried in order; UIAlertController matches both its title and message rules
- Argument extraction —
ArgumentSelector.firstUnlabeledskips opt-out forms likeText(verbatim:) - False-positive filtering — exclusions are applied; a
.notediagnostic is emitted if filtered - Context extraction — parent chain is walked to capture enclosing type, property, function
- Confidence scoring — base confidence (per rule) adjusted by content and context deltas
- Interpolation handling —
\(…)segments replaced with{…},hasInterpolationset,.warningemitted
Most iOS code generation tools work in one direction: they read your project's infrastructure — asset catalogs, string tables, storyboards — and generate typed Swift accessors from it. This is useful. SwiftL10n does this too.
The difference is the other direction.
SwiftL10n reads your source code and validates it against your infrastructure. When your code says Image("profile_icon"), SwiftL10n checks whether profile_icon.imageset actually exists in your asset catalog. When SwiftL10n.scan() runs during development, it scans every Image(…), Color(…), UIImage(named:), and UIColor(named:) call alongside the localization scan, cross-references them against every .xcassets catalog it finds, and warns about any name that does not exist — before any runtime crash occurs.
| Tool | Catalog → Code | Source → Catalog validation |
|---|---|---|
| SwiftGen | ✓ Typed accessors generated from catalog | ✗ Does not check how source references the catalog |
| R.swift | ✓ Typed accessors generated from catalog | ✗ Does not check how source references the catalog |
| SwiftL10n | ✓ Typed accessors generated from catalog | ✓ Validates source references against catalog |
SwiftGen and R.swift solve a different problem: they make invalid asset references compile errors by replacing string literals with typed constants. That is a valid and useful approach. SwiftL10n's validation layer solves a different class of failure — the asset reference that compiles fine, passes every test, ships to the App Store, and then silently fails to render because the catalog name was misspelled or the asset was deleted after the typed accessor was written.
The two approaches are complementary. SwiftL10n does not replace SwiftGen or R.swift. It adds a usage-analysis layer that works from the other direction.
The same principle applies to localization: SwiftL10n knows which string keys your code uses and can report which ones are absent from the catalog. Source usage analysis is the layer that existing tooling does not provide.
v0.6.2 — Production stable.
Two infrastructure domains are active:
| Domain | Status | Generated output |
|---|---|---|
| Localization | Production stable | i18n.swift |
| Asset infrastructure | Production stable | Assets.swift + missing-asset diagnostics |
Reliable today:
SwiftL10n.scan(projectPath:)— single call generates both files and validates assets- String detection (SwiftUI + UIKit, 15 rules)
- False-positive filtering with documented exclusion reasons
- Confidence scoring (deterministic, 0.0–1.0)
i18n.swiftcode generation withString(localized:table:bundle:)API- Common string extraction across files (
i18n.Common) - Asset catalog parsing (all
.xcassetsstructures, namespace groups,provides-namespace) - Missing asset diagnostics (
Image("name")wherenameis absent from the catalog) Assets.swiftgeneration — namespace-aware, deterministic, collision-safe, iOS 13+- iOS / tvOS / watchOS / visionOS compatible (all Apple platforms)
.swiftl10n.ymlproject config with auto-discovery- JSON diagnostics output (
--format json) - Incremental scan cache (SHA-256 per-file,
.build/swiftl10n-cache.json) - 288 tests, 0 failures
SwiftL10n follows a deliberate, phase-gated roadmap. Stability in one phase is a prerequisite for the next. Scope is intentionally narrow — each phase solves one problem well rather than several problems partially.
| Version | Focus | Status |
|---|---|---|
| v0.1 – v0.4.x | Localization infrastructure foundation: SwiftUI + UIKit detection, confidence scoring, false-positive filtering, i18n.swift generation |
Released |
| v0.5.x | Developer workflow: .swiftl10n.yml config, ScanPipeline, JSON diagnostics, incremental SHA-256 cache |
Released |
| v0.6.0 | Asset infrastructure foundation: .xcassets catalog parsing, source-to-catalog cross-reference, missing asset diagnostics |
Released |
| v0.6.1 | Asset code generation: namespace-aware Assets.swift from catalog |
Released |
| v0.6.2 | SwiftL10n.scan() unified entry point; generateAssets() free function; iOS / tvOS / watchOS compatibility fix |
Released |
| v0.7 | Diagnostics ergonomics: // swiftl10n:ignore suppression, fix suggestions, confidence explanations in --verbose, GitHub Actions annotation format, ImageResource opt-in for iOS 16+ projects |
Planned |
| v0.8 | Scale and reliability: large-project benchmarking (50k+ LOC), parallel file scanning, incremental cache hardening, multi-module namespace collision handling | Planned |
| v0.9 | Resource consistency: .xcstrings key existence validation, duplicate localization analysis, accessibility label completeness diagnostics |
Planned |
| v1.0 | Stability: public API contracts with semantic versioning guarantees, Swift Package Index integration, production-ready CI guides | Planned |
Each phase is additive. APIs released in earlier phases are not removed in later ones without a major version bump.
What will not appear on this roadmap: automatic source rewriting, design token inference, generic constants generation, build-time mutations, or any feature that requires semantic type analysis that SwiftSyntax cannot provide. See Permanent Architectural Boundaries.
This is not a limitation. It is an architectural boundary, and the reasoning is worth understanding.
Replacing Text("Save") with a typed call looks straightforward. It is not, because the correct replacement cannot be determined at a single call site:
// "Save" appears in SettingsView.swift, HomeView.swift, and ProfileView.swift.
// CommonStringExtractor promotes it to i18n.Common.
// The correct replacement everywhere is:
Text(i18n.Common.save())
// But if "Save" only appeared in SettingsView.swift, it would stay in its namespace.
// The correct replacement would be:
Text(i18n.Settings.save())The destination namespace is determined by the full project scan result — the set of all other occurrences of the same string, the outcome of common-string extraction, and the file-to-namespace mapping from NamespaceInferrer. None of this information is available at a single call site. A tool that rewrites files individually, without the full scan result, generates wrong code. A tool that completes the full scan and then rewrites files is making permanent changes to production source based on analysis the developer has not reviewed.
SwiftUI's Text initializer has multiple overloads. One accepts LocalizedStringKey; another accepts String. The generated i18n.Settings.save() returns String. These are not interchangeable:
Text("Save") // LocalizedStringKey — integrates with Xcode string extraction
Text(i18n.Settings.save()) // String — different type, different runtime pathReplacing one with the other changes the type of the expression without a compiler error. The behavior difference may be subtle — extraction tooling stops seeing the key, runtime fallback behavior changes — and it will not be caught by most test suites. SwiftSyntax cannot determine which overload a call site resolves to without full type information. Full type information requires a running Swift compiler.
Custom view initializers, modifier chains, and function overloads compound this problem. Every call site in every project requires independent semantic verification. This does not reduce to a pattern-matching exercise.
SwiftL10n generates i18n.swift — the complete, correct, type-safe API — and stops there. The developer applies replacements intentionally, one file at a time, using Xcode autocomplete:
- Run
SwiftL10n.scan(projectPath:)once, orswiftl10n scanfrom the terminal - Drag
Generated/i18n.swiftandGenerated/Assets.swiftinto your Xcode project - Navigate to a file with hardcoded strings or asset literals
- Type
i18n.orAssets.where accepted — Xcode autocompletes the namespace and function name - The compiler confirms every replacement is type-correct
This takes minutes per file, is verifiably correct at every step, and gives the developer full understanding of every change. The generated API is the scaffolding; the developer is the one who applies it.
These constraints are not scheduled features. They are permanent decisions that protect the tool's correctness and the developer's trust.
No automatic source rewriting. SwiftL10n reads source; it does not modify it. See Why SwiftL10n Never Rewrites Your Source.
No design token inference. Detecting .cornerRadius(16) and suggesting DesignSystem.cornerRadius.medium is speculative. The same literal appears in unrelated semantic contexts throughout any real project. Precision is not achievable at scale, and an imprecise design token tool causes more harm than no tool.
No build-time mutations. Files are written only when you explicitly call SwiftL10n.scan() or run swiftl10n scan. SwiftL10n is a development workflow tool. Hidden file modifications during compilation are unacceptable regardless of how useful the modification would be.
No opaque heuristics. Every diagnostic has a documented, traceable reason. FalsePositiveFilter exclusions are enumerated cases. Confidence scores are additive delta models with documented deltas. Nothing is a black box.
No generic constants generation. Typed wrappers for fonts, spacing, and arbitrary project values require a design system contract SwiftL10n does not have. A tool that reaches for problems outside its domain solves none of them reliably.
No merged infrastructure namespaces. i18n.Assets.profileIcon will never exist. Localization keys and asset names have different runtime semantics, different failure modes, and different generation pipelines. They must remain in separate generated files.
| Minimum | |
|---|---|
| Swift | 6.0+ |
| Xcode | 16+ |
| macOS | 13+ |
| iOS | 13+ |
| tvOS | 13+ |
| watchOS | 6+ |
| visionOS | 1+ |
dependencies: [
.package(url: "https://github.com/GRimAce11/SwiftL10n.git", from: "0.6.2"),
],
targets: [
.target(
name: "YourApp",
dependencies: [
.product(name: "SwiftL10nCore", package: "SwiftL10n"),
]
),
]Or in Xcode: File → Add Package Dependencies → enter the URL → tick only SwiftL10nCore.
git clone https://github.com/GRimAce11/SwiftL10n.git
cd SwiftL10n
swift build -c release
cp .build/release/swiftl10n /usr/local/bin/
swiftl10n --versionswift test| File | What it tests |
|---|---|
ScannerTests.swift |
End-to-end scanner behaviour for every rule |
DetectionRuleTests.swift |
Each rule in isolation |
FalsePositiveFilterTests.swift |
Every exclusion reason + valid pass-throughs |
ConfidenceScorerTests.swift |
Scoring deltas, clamping, context boosts |
ContextExtractorTests.swift |
struct/class/extension/function/property extraction |
InterpolationTests.swift |
Detection, warning, template value, codegen exclusion |
FixtureTests.swift |
Integration tests on realistic SwiftUI fixtures |
NamespaceInferrerTests.swift |
Suffix stripping, collision handling |
DiagnosticsTests.swift |
Severity filtering, formatting |
UIKitDetectionTests.swift |
All 12 UIKit patterns |
CodeGeneratorTests.swift |
Deduplication, decorator stripping |
ConfigTests.swift |
YAML decode, round-trip, defaults, AssetsOutputConfig |
ConfigLoaderTests.swift |
Discovery walking, load errors, validation |
GlobMatcherTests.swift |
*, **, ?, edge cases |
ScanPipelineTests.swift |
Multi-file scan, exclusion, confidence filter |
IncrementalScanCacheTests.swift |
SHA-256 hashing, cache hit/miss, round-trip, integration |
AssetCatalogTests.swift |
Parser, namespace groups, appiconset/symbolset skipped, findCatalogs, merged |
AssetScannerTests.swift |
All five call-site forms, Image(systemName:) excluded, validation |
AssetCodeGeneratorTests.swift |
Identifier/type-name conversion, namespaces, collisions, determinism, config |
AssetsGeneratorTests.swift |
generateAssets() end-to-end: catalog discovery, output path, parent-dir lookup |
| Document | Description |
|---|---|
| Production Guide | Step-by-step: install, scan, generate, migrate, CI/CD, custom rules |
| CONTRIBUTING.md | How to add detection rules and submit PRs |
| CHANGELOG.md | Version history |
Examples/SwiftL10nDemo/ is a macOS SwiftUI app demonstrating the full API. Open it two ways:
- Xcode project: open
Examples/SwiftL10nDemo/SwiftL10nDemo.xcodeprojdirectly - Swift Package: open
Examples/SwiftL10nDemo/Package.swiftin Xcode or runswift runin that directory
See CONTRIBUTING.md.
SwiftL10n is available under the MIT License.