Skip to content

Commit 9a61f48

Browse files
Add prefer_asset_symbols rule (#6261)
Co-authored-by: Danny Mösch <[email protected]>
1 parent a0414c9 commit 9a61f48

File tree

9 files changed

+144
-24
lines changed

9 files changed

+144
-24
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,13 @@
3939
* `sorted_imports`
4040
<!-- Keep empty line to have the contributors on a separate line. -->
4141
[SimplyDanny](https://github.com/SimplyDanny)
42+
43+
* Add new `prefer_asset_symbols` rule that suggests using asset symbols over
44+
string-based image initialization to avoid typos and enable compile-time
45+
checking. This rule detects `UIImage(named:)` and `SwiftUI.Image(_:)` calls
46+
with string literals and suggests using asset symbols instead.
47+
[danglingP0inter](https://github.com/danglingP0inter)
48+
[#5939](https://github.com/realm/SwiftLint/issues/5939)
4249

4350
### Bug Fixes
4451

Source/SwiftLintBuiltInRules/Models/BuiltInRules.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ public let builtInRules: [any Rule.Type] = [
149149
OverrideInExtensionRule.self,
150150
PatternMatchingKeywordsRule.self,
151151
PeriodSpacingRule.self,
152+
PreferAssetSymbolsRule.self,
152153
PreferConditionListRule.self,
153154
PreferKeyPathRule.self,
154155
PreferNimbleRule.self,
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import SwiftLintCore
2+
import SwiftSyntax
3+
4+
@SwiftSyntaxRule(optIn: true)
5+
struct PreferAssetSymbolsRule: Rule {
6+
var configuration = SeverityConfiguration<Self>(.warning)
7+
8+
static let description = RuleDescription(
9+
identifier: "prefer_asset_symbols",
10+
name: "Prefer Asset Symbols",
11+
description: "Prefer using asset symbols over string-based image initialization",
12+
rationale: """
13+
`UIKit.UIImage(named:)` and `SwiftUI.Image(_:)` bear the risk of bugs due to typos in their string \
14+
arguments. Since Xcode 15, Xcode generates codes for images in the Asset Catalog. Usage of these codes \
15+
and system icons from SF Symbols avoid typos and allow for compile-time checking.
16+
""",
17+
kind: .idiomatic,
18+
minSwiftVersion: .fiveDotNine,
19+
nonTriggeringExamples: [
20+
// UIKit - using asset symbols
21+
Example("UIImage(resource: .someImage)"),
22+
Example("UIImage(systemName: \"trash\")"),
23+
// SwiftUI - using asset symbols
24+
Example("Image(.someImage)"),
25+
Example("Image(systemName: \"trash\")"),
26+
// Dynamic strings (variables or interpolated)
27+
Example("UIImage(named: imageName)"),
28+
Example("UIImage(named: \"image_\\(suffix)\")"),
29+
Example("Image(imageName)"),
30+
Example("Image(\"image_\\(suffix)\")"),
31+
],
32+
triggeringExamples: [
33+
// UIKit examples
34+
Example("↓UIImage(named: \"some_image\")"),
35+
Example("↓UIImage(named: \"some image\")"),
36+
Example("↓UIImage.init(named: \"someImage\")"),
37+
// UIKit with bundle parameters
38+
Example("↓UIImage(named: \"someImage\", in: Bundle.main, compatibleWith: nil)"),
39+
Example("↓UIImage(named: \"someImage\", in: .main)"),
40+
// SwiftUI examples
41+
Example("↓Image(\"some_image\")"),
42+
Example("↓Image(\"some image\")"),
43+
Example("↓Image.init(\"someImage\")"),
44+
// SwiftUI with bundle parameters
45+
Example("↓Image(\"someImage\", bundle: Bundle.main)"),
46+
Example("↓Image(\"someImage\", bundle: .main)"),
47+
]
48+
)
49+
}
50+
51+
private extension PreferAssetSymbolsRule {
52+
final class Visitor: ViolationsSyntaxVisitor<ConfigurationType> {
53+
override func visitPost(_ node: FunctionCallExprSyntax) {
54+
// Check for UIImage(named:) or SwiftUI Image(_:) calls
55+
if isImageInit(node: node, className: "UIImage", argumentLabel: "named") ||
56+
isImageInit(node: node, className: "Image", argumentLabel: nil) {
57+
violations.append(node.positionAfterSkippingLeadingTrivia)
58+
}
59+
}
60+
61+
private func isImageInit(node: FunctionCallExprSyntax, className: String, argumentLabel: String?) -> Bool {
62+
// Check if this is the specified class or class.init call using syntax tree matching
63+
guard isImageCall(node.calledExpression, className: className) else {
64+
return false
65+
}
66+
67+
// Check if the first argument has the expected label and is a string literal
68+
guard let firstArgument = node.arguments.first,
69+
firstArgument.label?.text == argumentLabel,
70+
let stringLiteral = firstArgument.expression.as(StringLiteralExprSyntax.self),
71+
stringLiteral.isConstantString else {
72+
return false
73+
}
74+
75+
return true
76+
}
77+
78+
private func isImageCall(_ expression: ExprSyntax, className: String) -> Bool {
79+
// Match ClassName directly
80+
if let identifierExpr = expression.as(DeclReferenceExprSyntax.self) {
81+
return identifierExpr.baseName.text == className
82+
}
83+
84+
// Match ClassName.init
85+
if let memberAccessExpr = expression.as(MemberAccessExprSyntax.self),
86+
let baseExpr = memberAccessExpr.base?.as(DeclReferenceExprSyntax.self),
87+
baseExpr.baseName.text == className,
88+
memberAccessExpr.declName.baseName.text == "init" {
89+
return true
90+
}
91+
92+
return false
93+
}
94+
}
95+
}
96+
97+
private extension StringLiteralExprSyntax {
98+
var isConstantString: Bool {
99+
segments.allSatisfy { $0.is(StringSegmentSyntax.self) }
100+
}
101+
}

Tests/GeneratedTests/GeneratedTests_06.swift

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -139,20 +139,20 @@ final class PeriodSpacingRuleGeneratedTests: SwiftLintTestCase {
139139
}
140140
}
141141

142-
final class PreferConditionListRuleGeneratedTests: SwiftLintTestCase {
142+
final class PreferAssetSymbolsRuleGeneratedTests: SwiftLintTestCase {
143143
func testWithDefaultConfiguration() {
144-
verifyRule(PreferConditionListRule.description)
144+
verifyRule(PreferAssetSymbolsRule.description)
145145
}
146146
}
147147

148-
final class PreferKeyPathRuleGeneratedTests: SwiftLintTestCase {
148+
final class PreferConditionListRuleGeneratedTests: SwiftLintTestCase {
149149
func testWithDefaultConfiguration() {
150-
verifyRule(PreferKeyPathRule.description)
150+
verifyRule(PreferConditionListRule.description)
151151
}
152152
}
153153

154-
final class PreferNimbleRuleGeneratedTests: SwiftLintTestCase {
154+
final class PreferKeyPathRuleGeneratedTests: SwiftLintTestCase {
155155
func testWithDefaultConfiguration() {
156-
verifyRule(PreferNimbleRule.description)
156+
verifyRule(PreferKeyPathRule.description)
157157
}
158158
}

Tests/GeneratedTests/GeneratedTests_07.swift

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@
77
@testable import SwiftLintCore
88
import TestHelpers
99

10+
final class PreferNimbleRuleGeneratedTests: SwiftLintTestCase {
11+
func testWithDefaultConfiguration() {
12+
verifyRule(PreferNimbleRule.description)
13+
}
14+
}
15+
1016
final class PreferSelfInStaticReferencesRuleGeneratedTests: SwiftLintTestCase {
1117
func testWithDefaultConfiguration() {
1218
verifyRule(PreferSelfInStaticReferencesRule.description)
@@ -150,9 +156,3 @@ final class RedundantSelfInClosureRuleGeneratedTests: SwiftLintTestCase {
150156
verifyRule(RedundantSelfInClosureRule.description)
151157
}
152158
}
153-
154-
final class RedundantSendableRuleGeneratedTests: SwiftLintTestCase {
155-
func testWithDefaultConfiguration() {
156-
verifyRule(RedundantSendableRule.description)
157-
}
158-
}

Tests/GeneratedTests/GeneratedTests_08.swift

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@
77
@testable import SwiftLintCore
88
import TestHelpers
99

10+
final class RedundantSendableRuleGeneratedTests: SwiftLintTestCase {
11+
func testWithDefaultConfiguration() {
12+
verifyRule(RedundantSendableRule.description)
13+
}
14+
}
15+
1016
final class RedundantSetAccessControlRuleGeneratedTests: SwiftLintTestCase {
1117
func testWithDefaultConfiguration() {
1218
verifyRule(RedundantSetAccessControlRule.description)
@@ -150,9 +156,3 @@ final class SwitchCaseAlignmentRuleGeneratedTests: SwiftLintTestCase {
150156
verifyRule(SwitchCaseAlignmentRule.description)
151157
}
152158
}
153-
154-
final class SwitchCaseOnNewlineRuleGeneratedTests: SwiftLintTestCase {
155-
func testWithDefaultConfiguration() {
156-
verifyRule(SwitchCaseOnNewlineRule.description)
157-
}
158-
}

Tests/GeneratedTests/GeneratedTests_09.swift

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@
77
@testable import SwiftLintCore
88
import TestHelpers
99

10+
final class SwitchCaseOnNewlineRuleGeneratedTests: SwiftLintTestCase {
11+
func testWithDefaultConfiguration() {
12+
verifyRule(SwitchCaseOnNewlineRule.description)
13+
}
14+
}
15+
1016
final class SyntacticSugarRuleGeneratedTests: SwiftLintTestCase {
1117
func testWithDefaultConfiguration() {
1218
verifyRule(SyntacticSugarRule.description)
@@ -150,9 +156,3 @@ final class UnusedControlFlowLabelRuleGeneratedTests: SwiftLintTestCase {
150156
verifyRule(UnusedControlFlowLabelRule.description)
151157
}
152158
}
153-
154-
final class UnusedDeclarationRuleGeneratedTests: SwiftLintTestCase {
155-
func testWithDefaultConfiguration() {
156-
verifyRule(UnusedDeclarationRule.description)
157-
}
158-
}

Tests/GeneratedTests/GeneratedTests_10.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@
77
@testable import SwiftLintCore
88
import TestHelpers
99

10+
final class UnusedDeclarationRuleGeneratedTests: SwiftLintTestCase {
11+
func testWithDefaultConfiguration() {
12+
verifyRule(UnusedDeclarationRule.description)
13+
}
14+
}
15+
1016
final class UnusedEnumeratedRuleGeneratedTests: SwiftLintTestCase {
1117
func testWithDefaultConfiguration() {
1218
verifyRule(UnusedEnumeratedRule.description)

Tests/IntegrationTests/default_rule_configurations.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -857,6 +857,11 @@ period_spacing:
857857
meta:
858858
opt-in: true
859859
correctable: true
860+
prefer_asset_symbols:
861+
severity: warning
862+
meta:
863+
opt-in: true
864+
correctable: false
860865
prefer_condition_list:
861866
severity: warning
862867
meta:

0 commit comments

Comments
 (0)