Files
SwiftLint/Source/SwiftLintFramework/Rules/Lint/AccessibilityLabelForImageRule.swift
T
Danny Mösch 449190d324 Verify examples in rules by default and enforce explicit exclusion (#4065)
A rule must conform to ManuallyTestedExamplesRule to skip generation of a test for its examples.
2022-08-09 22:32:09 +02:00

220 lines
9.5 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.
public struct AccessibilityLabelForImageRule: ASTRule, ConfigurationProviderRule, OptInRule {
public var configuration = SeverityConfiguration(.warning)
public init() {}
public static let description = RuleDescription(
identifier: "accessibility_label_for_image",
name: "Accessibility Label for Image",
description: "All Images that provide context should have an accessibility label. " +
"Purely decorative images can be hidden from accessibility.",
kind: .lint,
minSwiftVersion: .fiveDotOne,
nonTriggeringExamples: AccessibilityLabelForImageRuleExamples.nonTriggeringExamples,
triggeringExamples: AccessibilityLabelForImageRuleExamples.triggeringExamples
)
// MARK: AST Rule
public 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 {
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 {
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 `accesibilityHidden(true)`
/// or `accessibility(hidden: true)` modifier.
func hasAccessibilityHiddenModifier(in file: SwiftLintFile) -> Bool {
guard expressionKind == .call, let name = name else {
return false
}
// Check for iOS 14+ version of modifier
if name.hasSuffix("accessibilityHidden") && getSingleUnnamedArgumentValue(in: file) == "true" {
return true
}
// Check for iOS 13 version of modifier
if name.hasSuffix("accessibility"),
let hiddenArg = enclosedArguments.first(where: { $0.name == "hidden" }),
hiddenArg.getArgumentValue(in: file) == "true" {
return true
}
// Recursively check substructure.
// SwiftUI literal Views with modifiers will have a SourceKittenDictionary structure like:
// Image("myImage").resizable().accessibility(hidden: true).frame
// '--> Image("myImage").resizable().accessibility
return substructure.contains(where: { $0.hasAccessibilityHiddenModifier(in: file) })
}
/// Whether or not the dictionary represents a SwiftUI View with an `accesibilityLabel(_:)`
/// or `accessibility(label:)` modifier.
var hasAccessibilityLabelModifier: Bool {
guard expressionKind == .call, let name = name else {
return false
}
// Check for iOS 14+ version of modifier.
if name.hasSuffix("accessibilityLabel") {
return true
}
// Check for iOS 13 version of modifier.
if name.hasSuffix("accessibility"), enclosedArguments.contains(where: { $0.name == "label" }) {
return true
}
// Recursively check substructure.
// SwiftUI literal Views with modifiers will have a SourceKittenDictionary structure like:
// Image("myImage").resizable().accessibilityLabel(Text("Label")).frame
// '--> Image("myImage").resizable().accessibilityLabel
return substructure.contains(where: { $0.hasAccessibilityLabelModifier })
}
/// Whether or not the dictionary represents a SwiftUI View with an `accessibilityElement()` or
/// `accessibilityElement(children: .ignore)` modifier (`.ignore` is the default parameter value).
func hasAccessibilityElementChildrenIgnoreModifier(in file: SwiftLintFile) -> Bool {
guard expressionKind == .call, let name = name else {
return false
}
// Check for modifier.
if name.hasSuffix("accessibilityElement") {
if enclosedArguments.isEmpty {
return true
}
if let childrenArg = enclosedArguments.first(where: { $0.name == "children" }),
childrenArg.getArgumentValue(in: file) == ".ignore" {
return true
}
}
// Recursively check substructure.
// SwiftUI literal Views with modifiers will have a SourceKittenDictionary structure like:
// VStack { ... }.accessibilityElement().padding
// '--> VStack { ... }.accessibilityElement
return substructure.contains(where: { $0.hasAccessibilityElementChildrenIgnoreModifier(in: file) })
}
/// Helper to get the value of an argument.
func getArgumentValue(in file: SwiftLintFile) -> String? {
guard expressionKind == .argument, let bodyByteRange = bodyByteRange else {
return nil
}
return file.stringView.substringWithByteRange(bodyByteRange)
}
/// Helper to get the value of a single unnamed argument to a function call.
func getSingleUnnamedArgumentValue(in file: SwiftLintFile) -> String? {
guard expressionKind == .call, let bodyByteRange = bodyByteRange else {
return nil
}
return file.stringView.substringWithByteRange(bodyByteRange)
}
}