mirror of
https://github.com/Homebrew/BrewUI.git
synced 2026-10-02 08:34:36 +08:00
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:
co-authored by
Claude Opus 5
parent
f4f950f1be
commit
07e0ce1570
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user