|
| 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 | +} |
0 commit comments