Lint localised copy and enforce the UI-only layer

BrewUILint gains localized_copy (every literal is Text(bundle:comment:),
String(localized:bundle:comment:) or Text(verbatim:)) and
localization_layer (no localisation outside BrewUIComponents,
BrewFeature* and the app). The build workflow checks the catalogs
against the code after its Xcode build, the Swift Quality job runs
scripts/localize verify and writes the translation status to the step
summary. Also points the lint and build workflows at Homebrew/ — the
app folder was renamed from Brew/ and the workflows never followed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Graeme Arthur
2026-09-22 23:24:46 +10:00
co-authored by Claude Opus 5
parent f4f950f1be
commit 07e0ce1570
12 changed files with 681 additions and 0 deletions
+7
View File
@@ -15,6 +15,7 @@ on:
- "Package.swift"
- "Package.resolved"
- "Tools/BrewUILint/**"
- "scripts/localize"
- "scripts/test"
- "scripts/test-ui"
- "Brewfile"
@@ -35,6 +36,7 @@ on:
- "Package.swift"
- "Package.resolved"
- "Tools/BrewUILint/**"
- "scripts/localize"
- "scripts/test"
- "scripts/test-ui"
- "Mintfile"
@@ -90,6 +92,11 @@ jobs:
CODE_SIGNING_ALLOWED=NO \
| tee xcodebuild.log
# Xcode emits the compiler's string data during the build above but only the IDE writes it
# back into the catalogs, so do that here and fail if a catalog no longer matches the code.
- name: Check string catalogs are in sync
run: scripts/localize sync --check --skip-build --derived-data DerivedData
- name: Capture Swift toolchain fingerprint
id: swift-toolchain
run: echo "fingerprint=$(swift --version | shasum -a 256 | cut -d' ' -f1)" >> "$GITHUB_OUTPUT"
+13
View File
@@ -6,6 +6,7 @@ on:
- main
paths:
- "**/*.swift"
- "**/*.xcstrings"
- ".swiftlint*"
- ".swiftformat"
- "Brewfile"
@@ -13,11 +14,13 @@ on:
- "Tools/BrewUILint/**"
- "scripts/bootstrap"
- "scripts/pre-commit"
- "scripts/localize"
- ".github/workflows/swift_quality.yml"
pull_request:
types: [opened, reopened, synchronize]
paths:
- "**/*.swift"
- "**/*.xcstrings"
- ".swiftlint*"
- ".swiftformat"
- "Brewfile"
@@ -25,6 +28,7 @@ on:
- "Tools/BrewUILint/**"
- "scripts/bootstrap"
- "scripts/pre-commit"
- "scripts/localize"
- ".github/workflows/swift_quality.yml"
permissions:
@@ -85,3 +89,12 @@ jobs:
run: |
BREWUILINT="$(swift build --package-path Tools/BrewUILint -c release --show-bin-path)/BrewUILint"
find Homebrew HomebrewUpgradeHelper Sources -name '*.swift' -print0 | xargs -0 "$BREWUILINT"
# Catalogs only in UI targets, and no empty translations (those render blank instead of
# falling back to English). Drift against the code is checked after the build in pr_build_test.
- name: Verify string catalogs
run: scripts/localize verify
# The quiet maintainer signal: what still needs translating, in the job summary, never a failure.
- name: Localization status
run: scripts/localize status --markdown >> "$GITHUB_STEP_SUMMARY"
@@ -0,0 +1,82 @@
import SwiftSyntax
/// Which initializer arguments of each project type are declared `LocalizedStringResource`, from
/// both the memberwise initializer and explicit `init`s. `Runner` collects these across all files
/// before linting.
final class CopyParameterCollector: SyntaxVisitor {
/// Stands in for the first argument of a call when the initializer declares it unlabeled.
static let positionalLabel = "_"
private(set) var copyParameters: [String: Set<String>] = [:]
override init(viewMode: SyntaxTreeViewMode) {
super.init(viewMode: viewMode)
}
override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind {
record(typeName: node.name.text, members: node.memberBlock.members)
return .visitChildren
}
override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind {
record(typeName: node.name.text, members: node.memberBlock.members)
return .visitChildren
}
private func record(typeName: String, members: MemberBlockItemListSyntax) {
var labels: Set<String> = []
for member in members {
if let variable = member.decl.as(VariableDeclSyntax.self) {
labels.formUnion(storedCopyProperties(variable))
}
if let initializer = member.decl.as(InitializerDeclSyntax.self) {
labels.formUnion(copyParameters(of: initializer))
}
}
guard !labels.isEmpty else {
return
}
copyParameters[typeName, default: []].formUnion(labels)
}
/// Stored properties only: a computed property is not a memberwise-initializer parameter.
private func storedCopyProperties(_ variable: VariableDeclSyntax) -> Set<String> {
var labels: Set<String> = []
for binding in variable.bindings {
guard binding.accessorBlock == nil,
let annotation = binding.typeAnnotation,
isCopyType(annotation.type),
let identifier = binding.pattern.as(IdentifierPatternSyntax.self)
else {
continue
}
labels.insert(identifier.identifier.text)
}
return labels
}
private func copyParameters(of initializer: InitializerDeclSyntax) -> Set<String> {
var labels: Set<String> = []
for (index, parameter) in initializer.signature.parameterClause.parameters.enumerated()
where isCopyType(parameter.type)
{
let label = parameter.firstName.text
if label == Self.positionalLabel {
// Only a leading `_` is matchable; later ones shift once earlier arguments default away.
if index == 0 {
labels.insert(Self.positionalLabel)
}
} else {
labels.insert(label)
}
}
return labels
}
private func isCopyType(_ type: TypeSyntax) -> Bool {
if let optional = type.as(OptionalTypeSyntax.self) {
return isCopyType(optional.wrappedType)
}
return type.as(IdentifierTypeSyntax.self)?.name.text == "LocalizedStringResource"
}
}
@@ -0,0 +1,30 @@
import SwiftSyntax
/// `SwiftUI.Text("…")` is the same call as `Text("…")`, so a module prefix must not hide copy from
/// a rule.
enum ModuleQualification {
static let moduleNames: Set<String> = ["Swift", "Foundation", "SwiftUI", "AppKit"]
}
extension MemberAccessExprSyntax {
/// Whether the callee is reached through a value rather than named directly.
var callsThroughValue: Bool {
guard let base else {
return false
}
guard let reference = base.as(DeclReferenceExprSyntax.self) else {
return true
}
return !ModuleQualification.moduleNames.contains(reference.baseName.text)
}
}
extension MemberTypeSyntax {
/// Whether the type is only prefixed with the module it comes from.
var namesAModuleMember: Bool {
guard let base = baseType.as(IdentifierTypeSyntax.self) else {
return false
}
return ModuleQualification.moduleNames.contains(base.name.text)
}
}
@@ -8,14 +8,18 @@ final class RuleContext {
/// in this run. Populated by `Runner` before rules walk; rules use this to relate an extension
/// declared in one file back to the type it extends in another.
let nonisolatedTypeNames: Set<String>
/// Type name -> its initializer labels declared `LocalizedStringResource` (`"_"` when unlabeled).
let copyParameters: [String: Set<String>]
init(
file: String,
tree: SourceFileSyntax,
nonisolatedTypeNames: Set<String> = [],
copyParameters: [String: Set<String>] = [:],
) {
self.file = file
self.nonisolatedTypeNames = nonisolatedTypeNames
self.copyParameters = copyParameters
converter = SourceLocationConverter(fileName: file, tree: tree)
}
@@ -2,5 +2,7 @@ enum RuleRegistry {
static let allRules: [any Rule.Type] = [
PackageIDRule.self,
NonisolatedExtensionRule.self,
LocalizedCopyRule.self,
LocalizationLayerRule.self,
]
}
@@ -13,6 +13,7 @@ enum Runner {
var parsedFiles: [(path: String, tree: SourceFileSyntax)] = []
parsedFiles.reserveCapacity(files.count)
var nonisolatedTypeNames: Set<String> = []
var copyParameters: [String: Set<String>] = [:]
for path in files {
let source = try String(contentsOfFile: path, encoding: .utf8)
@@ -22,6 +23,10 @@ enum Runner {
let collector = NonisolatedTypeCollector(viewMode: .sourceAccurate)
collector.walk(tree)
nonisolatedTypeNames.formUnion(collector.nonisolatedTypeNames)
let copyCollector = CopyParameterCollector(viewMode: .sourceAccurate)
copyCollector.walk(tree)
copyParameters.merge(copyCollector.copyParameters) { $0.union($1) }
}
for (path, tree) in parsedFiles {
@@ -29,6 +34,7 @@ enum Runner {
file: path,
tree: tree,
nonisolatedTypeNames: nonisolatedTypeNames,
copyParameters: copyParameters,
)
for ruleType in RuleRegistry.allRules {
@@ -0,0 +1,76 @@
import SwiftSyntax
/// Only UI packages may hold user-facing copy.
struct LocalizationLayerRule: Rule {
static let identifier = "localization_layer"
static let message =
"User-facing copy lives only in UI packages (Sources/BrewUIComponents, Sources/BrewFeature*, Homebrew). " +
"Throw a typed error enum from this layer and word it in BrewUIComponents/Copy."
func makeVisitor(context: RuleContext) -> SyntaxVisitor {
LocalizationLayerVisitor(context: context)
}
/// Matched against any path suffix: CI passes absolute paths, the build plugin relative ones.
static func isUILayer(_ file: String) -> Bool {
let components = file.split(separator: "/").map(String.init)
guard let sourcesIndex = components.lastIndex(where: { $0 == "Sources" || $0 == "Homebrew" }) else {
return false
}
if components[sourcesIndex] == "Homebrew" {
return true
}
guard sourcesIndex + 1 < components.count else {
return false
}
let target = components[sourcesIndex + 1]
return target == "BrewUIComponents" || target.hasPrefix("BrewFeature")
}
static let localizingCalls: Set<String> = [
"NSLocalizedString", "LocalizedStringResource", "LocalizedStringKey",
]
}
private final class LocalizationLayerVisitor: SyntaxVisitor {
private let context: RuleContext
private let isUILayer: Bool
init(context: RuleContext) {
self.context = context
isUILayer = LocalizationLayerRule.isUILayer(context.file)
super.init(viewMode: .sourceAccurate)
}
override func visit(_ node: FunctionCallExprSyntax) -> SyntaxVisitorContinueKind {
guard !isUILayer, let name = calleeName(node.calledExpression) else {
return .visitChildren
}
let localizes = LocalizationLayerRule.localizingCalls.contains(name)
|| ((name == "String" || name == "AttributedString") && node.arguments.first?.label?.text == "localized")
|| (name == "Text" && node.arguments.first?.label == nil
&& node.arguments.first?.expression.is(StringLiteralExprSyntax.self) == true)
if localizes {
context.record(rule: LocalizationLayerRule.self, at: node)
return .skipChildren
}
return .visitChildren
}
override func visit(_ node: MacroExpansionExprSyntax) -> SyntaxVisitorContinueKind {
if !isUILayer, node.macroName.text == "bundle" {
context.record(rule: LocalizationLayerRule.self, at: node)
}
return .visitChildren
}
private func calleeName(_ expression: ExprSyntax) -> String? {
if let reference = expression.as(DeclReferenceExprSyntax.self) {
return reference.baseName.text
}
if let member = expression.as(MemberAccessExprSyntax.self), !member.callsThroughValue {
return member.declName.baseName.text
}
return nil
}
}
@@ -0,0 +1,195 @@
import SwiftSyntax
/// A package target looks strings up in `Bundle.main` unless told otherwise, so a bare `Text("…")`
/// silently never localises.
struct LocalizedCopyRule: Rule {
static let identifier = "localized_copy"
static let message =
"Copy must be `Text(\"…\", bundle: #bundle, comment: \"…\")`, " +
"`String(localized: \"…\", bundle: #bundle, comment: \"…\")`, " +
"`LocalizedStringResource(\"…\", bundle: #bundle, comment: \"…\")`, or `Text(verbatim:)` when it is not copy " +
"(versions, package names, commands). Carry copy as `String` or `LocalizedStringResource`, " +
"never `LocalizedStringKey`."
func makeVisitor(context: RuleContext) -> SyntaxVisitor {
LocalizedCopyVisitor(context: context)
}
/// Calls whose first unlabeled argument is a localised key with no way to pass a bundle.
static let keyTakingControls: Set<String> = [
"Button", "Label", "Toggle", "Section", "Link", "Menu", "Picker", "TextField", "SecureField",
"TableColumn", "ContentUnavailableView", "CommandMenu", "LabeledContent", "Tab", "DisclosureGroup",
"Stepper", "DatePicker", "NavigationLink", "ProgressView", "Alert", "MenuBarExtra", "TextEditor",
]
static let keyTakingModifiers: Set<String> = [
"help", "navigationTitle", "navigationSubtitle", "accessibilityLabel", "accessibilityHint",
"accessibilityValue", "accessibilityInputLabels", "alert", "confirmationDialog", "badge", "tabItem",
"navigationDocument", "toolbarTitleMenu", "typeSelectEquivalent",
]
/// Labeled arguments that take a localised key on otherwise-fine modifiers (`.searchable(prompt:)`).
static let keyTakingLabels: Set<String> = ["prompt"]
static let localizedInitializers: Set<String> = ["String", "AttributedString", "LocalizedStringResource"]
}
private final class LocalizedCopyVisitor: SyntaxVisitor {
private let context: RuleContext
/// Preview sample copy is exempt from the project-component check; it reaches no user.
private var previewDepth = 0
init(context: RuleContext) {
self.context = context
super.init(viewMode: .sourceAccurate)
}
override func visit(_ node: MacroExpansionDeclSyntax) -> SyntaxVisitorContinueKind {
if node.macroName.text == "Preview" {
previewDepth += 1
}
return .visitChildren
}
override func visitPost(_ node: MacroExpansionDeclSyntax) {
if node.macroName.text == "Preview" {
previewDepth -= 1
}
}
override func visit(_ node: MacroExpansionExprSyntax) -> SyntaxVisitorContinueKind {
if node.macroName.text == "Preview" {
previewDepth += 1
}
return .visitChildren
}
override func visitPost(_ node: MacroExpansionExprSyntax) {
if node.macroName.text == "Preview" {
previewDepth -= 1
}
}
override func visit(_ node: FunctionCallExprSyntax) -> SyntaxVisitorContinueKind {
guard let callee = calleeName(node.calledExpression) else {
return .visitChildren
}
let arguments = node.arguments
let first = arguments.first
if LocalizedCopyRule.localizedInitializers.contains(callee.name) {
// Only a literal declares a key; `String(localized: resource)` resolves an existing one.
let declaresKey = isCopyLiteral(first?.expression)
&& (first?.label?.text == "localized" || (callee.name == "LocalizedStringResource" && first?.label == nil))
if declaresKey, !carriesBundleAndComment(arguments) {
context.record(rule: LocalizedCopyRule.self, at: node)
}
} else if callee.name == "NSLocalizedString", !callee.isMember {
context.record(rule: LocalizedCopyRule.self, at: node)
} else if callee.name == "Text", !callee.isMember {
if let first, first.label == nil, isCopyLiteral(first.expression),
!carriesBundleAndComment(arguments)
{
context.record(rule: LocalizedCopyRule.self, at: node)
}
} else if callee.isMember ? LocalizedCopyRule.keyTakingModifiers.contains(callee.name)
: LocalizedCopyRule.keyTakingControls.contains(callee.name)
{
if let first, first.label == nil, isCopyLiteral(first.expression) {
context.record(rule: LocalizedCopyRule.self, at: first)
}
}
for argument in arguments
where argument.label.map({ LocalizedCopyRule.keyTakingLabels.contains($0.text) }) == true
&& callee.isMember
&& isCopyLiteral(argument.expression)
{
context.record(rule: LocalizedCopyRule.self, at: argument)
}
checkProjectCopyParameters(callee: callee, arguments: arguments)
return .visitChildren
}
/// `bundle: .main` reads the app bundle, where a package key is not, so the argument has to be
/// `#bundle` rather than merely present.
private func carriesBundleAndComment(_ arguments: LabeledExprListSyntax) -> Bool {
guard arguments.contains(where: { $0.label?.text == "comment" }),
let bundle = arguments.first(where: { $0.label?.text == "bundle" })
else {
return false
}
return bundle.expression.as(MacroExpansionExprSyntax.self)?.macroName.text == "bundle"
}
private func checkProjectCopyParameters(callee: Callee, arguments: LabeledExprListSyntax) {
guard previewDepth == 0, !callee.isMember,
let copyLabels = context.copyParameters[callee.name]
else {
return
}
for (index, argument) in arguments.enumerated() {
let matches = if let label = argument.label {
copyLabels.contains(label.text)
} else {
index == 0 && copyLabels.contains(CopyParameterCollector.positionalLabel)
}
if matches, isCopyLiteral(argument.expression) {
context.record(rule: LocalizedCopyRule.self, at: argument)
}
}
}
override func visit(_ node: IdentifierTypeSyntax) -> SyntaxVisitorContinueKind {
if node.name.text == "LocalizedStringKey" {
context.record(rule: LocalizedCopyRule.self, at: node)
}
return .visitChildren
}
override func visit(_ node: MemberTypeSyntax) -> SyntaxVisitorContinueKind {
if node.name.text == "LocalizedStringKey", node.namesAModuleMember {
context.record(rule: LocalizedCopyRule.self, at: node)
}
return .visitChildren
}
private struct Callee {
let name: String
let isMember: Bool
}
private func calleeName(_ expression: ExprSyntax) -> Callee? {
if let reference = expression.as(DeclReferenceExprSyntax.self) {
return Callee(name: reference.baseName.text, isMember: false)
}
if let member = expression.as(MemberAccessExprSyntax.self) {
return Callee(name: member.declName.baseName.text, isMember: member.callsThroughValue)
}
return nil
}
/// A string literal with at least one letter or interpolation; `""` and `"→"` are not copy.
/// Looks through `a ? "x" : "y"` and `a ?? "x"`, which parse as unresolved sequences.
private func isCopyLiteral(_ expression: ExprSyntax?) -> Bool {
guard let expression else {
return false
}
if let sequence = expression.as(SequenceExprSyntax.self) {
return sequence.elements.contains { element in
if let ternary = element.as(UnresolvedTernaryExprSyntax.self) {
return isCopyLiteral(ternary.thenExpression)
}
return isCopyLiteral(element)
}
}
guard let literal = expression.as(StringLiteralExprSyntax.self) else {
return false
}
return literal.segments.contains { segment in
segment.as(StringSegmentSyntax.self)?.content.text.contains(where: \.isLetter) == true
|| segment.is(ExpressionSegmentSyntax.self)
}
}
}
@@ -0,0 +1,57 @@
@testable import BrewUILint
import Testing
@Suite("LocalizationLayerRule")
struct LocalizationLayerRuleTests {
private static let localizing = [
#"let s = String(localized: "Homebrew command failed.", bundle: #bundle, comment: "c")"#,
#"let s = NSLocalizedString("Homebrew command failed.", comment: "")"#,
#"let r = LocalizedStringResource("Homebrew command failed.", bundle: #bundle, comment: "c")"#,
#"let b = #bundle"#,
#"let t = Text("Homebrew command failed.", bundle: #bundle, comment: "c")"#,
// A module prefix names the same call.
#"let s = Swift.String(localized: "Homebrew command failed.", bundle: #bundle, comment: "c")"#,
#"let s = Foundation.NSLocalizedString("Homebrew command failed.", comment: "")"#,
#"let t = SwiftUI.Text("Homebrew command failed.", bundle: #bundle, comment: "c")"#,
]
@Test(arguments: [
"Sources/BrewUIComponents/Copy/BrewErrorCopy.swift",
"Sources/BrewFeatureDoctor/Views/DoctorView.swift",
"Sources/BrewFeatureInstalled/ViewModels/InstalledViewModel.swift",
"Homebrew/Views/SidebarItem.swift",
"/Users/ci/work/BrewUI/Sources/BrewFeatureConsole/Views/ConsoleBody.swift",
])
func `UI layers may localise`(file: String) {
for source in Self.localizing {
#expect(LintHarness.lintLocalizationLayerRule(source, file: file).isEmpty, "\(source)")
}
}
@Test(arguments: [
"Sources/BrewCore/Operations/OperationFailure.swift",
"Sources/BrewRepositories/BrewInstalledPackagesRepository.swift",
"Sources/BrewRepositoryInterfaces/RepositoryError.swift",
"Sources/BrewCLI/BrewCommandService.swift",
"Sources/BrewNetworking/BrewAPIClient.swift",
"Sources/BrewServicesTestSupport/InstalledPackagesTestSupport.swift",
"/Users/ci/work/Homebrew/Sources/BrewCore/X.swift",
])
func `other layers may not localise`(file: String) {
for source in Self.localizing {
let violations = LintHarness.lintLocalizationLayerRule(source, file: file)
#expect(violations.count == 1 && violations.first?.ruleID == LocalizationLayerRule.identifier, "\(source)")
}
}
@Test
func `typed errors and plain strings are fine anywhere`() {
let source = """
public enum BrewRepositoryError: Error { case malformedBrewOutput(command: String) }
let diagnostic = "brew exited \\(code)"
logger.error("Tap refresh failed: \\(error.localizedDescription, privacy: .public)")
"""
let file = "Sources/BrewRepositories/BrewInstalledPackagesRepository.swift"
#expect(LintHarness.lintLocalizationLayerRule(source, file: file).isEmpty)
}
}
@@ -0,0 +1,187 @@
@testable import BrewUILint
import Testing
@Suite("LocalizedCopyRule")
struct LocalizedCopyRuleTests {
@Test(arguments: [
#"Text("Doctor", bundle: #bundle, comment: "Doctor tab heading")"#,
#"Text(verbatim: "v\(version)")"#,
#"Text(viewModel.subtitle)"#,
#"Text(count, format: .number)"#,
#"Button(String(localized: "Run Again", bundle: #bundle, comment: "Doctor: re-run")) {}"#,
#"Button(action: run) { Text(verbatim: "Debug") }"#,
#"let s = String(localized: "Loading…", bundle: #bundle, comment: "Subtitle")"#,
#"let s = String(localized: "\(count) packages", bundle: #bundle, comment: "%lld is the count")"#,
#"view.help(String(localized: "Dismiss", bundle: #bundle, comment: "Tooltip"))"#,
#"view.accessibilityLabel(title)"#,
#"let s = String(localized: viewModel.subtitle)"#,
#"let s = String(localized: section.title)"#,
#"Text("→")"#,
#"Text("")"#,
#"Label(item.title, systemImage: "gear")"#,
#"Image(systemName: "checkmark")"#,
#"Color("WindowBase", bundle: .module)"#,
#"logger.info("Refreshing installed packages")"#,
#"SwiftUI.Text("Doctor", bundle: #bundle, comment: "Doctor tab heading")"#,
])
func `accepted forms pass`(source: String) {
#expect(LintHarness.lintLocalizedCopyRule(source).isEmpty)
}
@Test(arguments: [
#"Text("Doctor")"#,
#"Text("Doctor", bundle: #bundle)"#,
#"Text("Doctor", comment: "Heading")"#,
#"Text("\(lead) \(time)")"#,
#"Button("Run Again") {}"#,
#"Button("Cancel", role: .cancel) {}"#,
#"Label("Refresh", systemImage: "arrow.clockwise")"#,
#"Toggle("Expand automatically", isOn: $flag)"#,
#"Picker("Scope", selection: $scope) {}"#,
#"Section("Details") {}"#,
#"Link("Homebrew Documentation", destination: url)"#,
#"Menu("Force Crash") {}"#,
#"CommandMenu("Debug") {}"#,
#"ContentUnavailableView("No activity", systemImage: "terminal")"#,
#"let s = String(localized: "Loading…")"#,
#"let s = String(localized: "Loading…", comment: "Subtitle")"#,
#"let s = String(localized: "Loading…", bundle: .module)"#,
#"let r = LocalizedStringResource("Run Again")"#,
#"let s = NSLocalizedString("Run Again", comment: "")"#,
#"view.help("Dismiss")"#,
#"view.navigationTitle("Doctor")"#,
#"view.navigationSubtitle("Browse \(count) packages")"#,
#"view.accessibilityLabel("Installed packages")"#,
#"view.accessibilityHint("Opens the package")"#,
#"view.alert("Failed", isPresented: $shown) {}"#,
#"view.confirmationDialog("Uninstall?", isPresented: $shown) {}"#,
#"view.searchable(text: $query, prompt: "Search Packages")"#,
#"view.accessibilityLabel(expanded ? "Hide console" : "Show console")"#,
#"view.accessibilityHint(hint ?? "Opens the package")"#,
#"var title: LocalizedStringKey { "Installed" }"#,
#"func row(actionTitle: LocalizedStringKey?) {}"#,
// A module prefix names the same call.
#"SwiftUI.Text("Doctor")"#,
#"let s = Swift.String(localized: "Loading…")"#,
#"let s = Foundation.NSLocalizedString("Run Again", comment: "")"#,
#"var title: SwiftUI.LocalizedStringKey { "Installed" }"#,
#"func row(actionTitle: SwiftUI.LocalizedStringKey?) {}"#,
// A bundle that is not the package's own never finds the key.
#"Text("Doctor", bundle: .main, comment: "Heading")"#,
#"let s = String(localized: "Loading…", bundle: Bundle.main, comment: "Subtitle")"#,
#"let r = LocalizedStringResource("Run Again", bundle: resourceBundle, comment: "Doctor: re-run")"#,
])
func `bare copy fails`(source: String) {
let violations = LintHarness.lintLocalizedCopyRule(source)
#expect(violations.count == 1 && violations.first?.ruleID == LocalizedCopyRule.identifier)
}
// MARK: - Project components
@Test(arguments: [
#"BrewActionButton("Retry", systemImage: "arrow.clockwise") {}"#,
#"BrewActionButton(LocalizedStringResource("Retry", bundle: #bundle, comment: "x"), confirmationTitle: "Copied") {}"#,
#"PackageDetailSectionHeading(title: "Details")"#,
#"CommandBlockView(command: viewModel.command, summaryText: "Installs this package")"#,
])
func `bare copy passed to a project component fails`(call: String) {
let source = """
struct BrewActionButton: View {
init(
_ title: LocalizedStringResource,
systemImage: String = "",
confirmationTitle: LocalizedStringResource? = nil,
action: () -> Void,
) {}
}
struct PackageDetailSectionHeading: View {
let title: LocalizedStringResource
}
struct CommandBlockView: View {
let command: String
let summaryText: LocalizedStringResource?
}
struct Caller: View {
var body: some View { \(call) }
}
"""
let violations = LintHarness.lintLocalizedCopyRule(source)
#expect(violations.count == 1 && violations.first?.ruleID == LocalizedCopyRule.identifier)
}
@Test(arguments: [
#"BrewActionButton(title, systemImage: "arrow.clockwise")"#,
#"CommandBlockView(command: "brew upgrade git")"#,
#"PackageDetailSectionHeading(title: LocalizedStringResource("Details", bundle: #bundle, comment: "x"))"#,
#"PackageDetailSectionHeading(title: viewModel.heading)"#,
#"SomeOtherType(title: "Details")"#,
])
func `non-copy arguments to a project component pass`(call: String) {
let source = """
struct BrewActionButton: View {
init(_ title: LocalizedStringResource, systemImage: String = "") {}
}
struct PackageDetailSectionHeading: View {
let title: LocalizedStringResource
}
struct CommandBlockView: View {
let command: String
let summaryText: LocalizedStringResource?
}
struct Caller: View {
var body: some View { \(call) }
}
"""
#expect(LintHarness.lintLocalizedCopyRule(source).isEmpty)
}
@Test
func `preview sample copy is exempt from the component check`() {
let source = """
struct NoteCallout: View {
let text: LocalizedStringResource
}
#if DEBUG
#Preview("Note callout") {
NoteCallout(text: "Casks and formulae are installed to different prefixes.")
}
#endif
"""
#expect(LintHarness.lintLocalizedCopyRule(source).isEmpty)
}
@Test
func `a preview does not exempt the SwiftUI checks`() {
let source = """
#Preview {
Button("Run Again") {}
}
"""
#expect(LintHarness.lintLocalizedCopyRule(source).count == 1)
}
@Test
func `debug-only code is held to the same rule`() {
let source = """
#if DEBUG
struct DebugMenu: Commands {
var body: some Commands { CommandMenu("Debug") {} }
}
#endif
"""
#expect(LintHarness.lintLocalizedCopyRule(source).count == 1)
}
@Test
func `violations point at the offending line`() {
let source = """
struct V: View {
var body: some View {
Text("Doctor", bundle: #bundle, comment: "Heading")
Button("Run Again") {}
}
}
"""
#expect(LintHarness.lintLocalizedCopyRule(source).map(\.line) == [4])
}
}
@@ -31,4 +31,26 @@ enum LintHarness {
visitor.walk(tree)
return context.violations
}
/// Does the same copy-parameter pre-pass `Runner` does, so one source string can declare a
/// component and call it.
static func lintLocalizedCopyRule(_ source: String, file: String = "Sources/BrewFeatureDoctor/Views/Test.swift") -> [Violation] {
let tree = Parser.parse(source: source)
let collector = CopyParameterCollector(viewMode: .sourceAccurate)
collector.walk(tree)
let context = RuleContext(file: file, tree: tree, copyParameters: collector.copyParameters)
LocalizedCopyRule().makeVisitor(context: context).walk(tree)
return context.violations
}
static func lintLocalizationLayerRule(_ source: String, file: String) -> [Violation] {
lint(LocalizationLayerRule(), source: source, file: file)
}
private static func lint(_ rule: some Rule, source: String, file: String) -> [Violation] {
let tree = Parser.parse(source: source)
let context = RuleContext(file: file, tree: tree)
rule.makeVisitor(context: context).walk(tree)
return context.violations
}
}