SwiftLint/Source/SwiftLintFramework/Rules/Lint/AccessibilityLabelForImageR...

144 lines
6.2 KiB
Swift

import SourceKittenFramework
/// In UIKit, a `UIImageView` was by default not an accessibility element, and would only be visible to VoiceOver
/// and other assistive technologies if the developer explicitly made them an accessibility element. In SwiftUI,
/// however, an `Image` is an accessibility element by default. If the developer does not explicitly hide them from
/// accessibility or give them an accessibility label, they will inherit the name of the image file, which often creates
/// a poor experience when VoiceOver reads things like "close icon white".
///
/// Known false negatives for Images declared as instance variables and containers that provide a label but are
/// not accessibility elements. Known false positives for Images created in a separate function from where they
/// have accessibility properties applied.
struct AccessibilityLabelForImageRule: ASTRule, ConfigurationProviderRule, OptInRule {
var configuration = SeverityConfiguration(.warning)
init() {}
static let description = RuleDescription(
identifier: "accessibility_label_for_image",
name: "Accessibility Label for Image",
description: "Images that provide context should have an accessibility label or should be explicitly hidden " +
"from accessibility",
kind: .lint,
minSwiftVersion: .fiveDotOne,
nonTriggeringExamples: AccessibilityLabelForImageRuleExamples.nonTriggeringExamples,
triggeringExamples: AccessibilityLabelForImageRuleExamples.triggeringExamples
)
// MARK: AST Rule
func validate(file: SwiftLintFile, kind: SwiftDeclarationKind,
dictionary: SourceKittenDictionary) -> [StyleViolation] {
// Only proceed to check View structs.
guard kind == .struct,
dictionary.inheritedTypes.contains("View"),
dictionary.substructure.isNotEmpty else {
return []
}
return findImageViolations(file: file, substructure: dictionary.substructure)
}
/// Recursively check a file for image violations, and return all such violations.
private func findImageViolations(file: SwiftLintFile, substructure: [SourceKittenDictionary]) -> [StyleViolation] {
var violations = [StyleViolation]()
for dictionary in substructure {
guard let offset: ByteCount = dictionary.offset else {
continue
}
// If it's image, and does not hide from accessibility or provide a label, it's a violation.
if dictionary.isImage {
if dictionary.isDecorativeOrLabeledOrSystemImage ||
dictionary.hasAccessibilityHiddenModifier(in: file) ||
dictionary.hasAccessibilityLabelModifier(in: file) {
continue
}
violations.append(
StyleViolation(ruleDescription: Self.description,
severity: configuration.severity,
location: Location(file: file, byteOffset: offset))
)
}
// If dictionary did not represent an Image, recursively check substructure,
// unless it's a container that hides its children from accessibility or is labeled.
else if dictionary.substructure.isNotEmpty {
if dictionary.hasAccessibilityHiddenModifier(in: file) ||
dictionary.hasAccessibilityElementChildrenIgnoreModifier(in: file) ||
dictionary.hasAccessibilityLabelModifier(in: file) {
continue
}
violations.append(contentsOf: findImageViolations(file: file, substructure: dictionary.substructure))
}
}
return violations
}
}
// MARK: SourceKittenDictionary extensions
private extension SourceKittenDictionary {
/// Whether or not the dictionary represents a SwiftUI Image.
/// Currently only accounts for SwiftUI image literals and not instance variables.
var isImage: Bool {
// Image literals will be reported as calls to the initializer.
guard expressionKind == .call else {
return false
}
if name == "Image" || name == "SwiftUI.Image" {
return true
}
// Recursively check substructure.
// SwiftUI literal Views with modifiers will have a SourceKittenDictionary structure like:
// Image(decorative: "myImage").resizable().frame
// --> Image(decorative: "myImage").resizable
// --> Image
return substructure.contains(where: { $0.isImage })
}
/// Whether or not the dictionary represents a SwiftUI Image using the `Image(decorative:)` constructor (hides
/// from a11y), or one of the `Image(_:label:)` or `Image(systemName:)` constructors (provides label).
var isDecorativeOrLabeledOrSystemImage: Bool {
guard isImage else {
return false
}
// Check for Image(decorative:), Image(_:label:), or Image(systemName:) constructor.
if expressionKind == .call &&
enclosedArguments.contains(where: { ["decorative", "label", "systemName"].contains($0.name) }) {
return true
}
// Recursively check substructure.
// SwiftUI literal Views with modifiers will have a SourceKittenDictionary structure like:
// Image(decorative: "myImage").resizable().frame
// --> Image(decorative: "myImage").resizable
// --> Image
return substructure.contains(where: { $0.isDecorativeOrLabeledOrSystemImage })
}
/// Whether or not the dictionary represents a SwiftUI View with an `accesibilityLabel(_:)`
/// or `accessibility(label:)` modifier.
func hasAccessibilityLabelModifier(in file: SwiftLintFile) -> Bool {
return hasModifier(
anyOf: [
SwiftUIModifier(
name: "accessibilityLabel",
arguments: [.init(name: "", values: [])]
),
SwiftUIModifier(
name: "accessibility",
arguments: [.init(name: "label", values: [])]
)
],
in: file
)
}
}