mirror of
https://github.com/realm/SwiftLint.git
synced 2026-05-07 20:12:49 +00:00
277 lines
12 KiB
Swift
277 lines
12 KiB
Swift
import SwiftSyntax
|
|
|
|
@SwiftSyntaxRule(optIn: true)
|
|
struct AccessibilityTraitForButtonRule: Rule {
|
|
var configuration = SeverityConfiguration<Self>(.warning)
|
|
|
|
static let description = RuleDescription(
|
|
identifier: "accessibility_trait_for_button",
|
|
name: "Accessibility Trait for Button",
|
|
description: "All views with tap gestures added should include the .isButton or the .isLink accessibility " +
|
|
"traits",
|
|
rationale: """
|
|
The accessibility button and link traits are used to tell assistive technologies that an element is tappable. \
|
|
When an element has one of these traits, VoiceOver will automatically read "button" or "link" after the \
|
|
element's label to let the user know that they can activate it.
|
|
|
|
When using a UIKit `UIButton` or SwiftUI `Button` or `Link`, the button trait is added by default, but when \
|
|
you manually add a tap gesture recognizer to an element, you need to explicitly add the button or link trait. \
|
|
|
|
In most cases the button trait should be used, but for buttons that open a URL in an external browser we use \
|
|
the link trait instead. This rule attempts to catch uses of the SwiftUI `.onTapGesture` modifier where the \
|
|
`.isButton` or `.isLink` trait is not explicitly applied.
|
|
""",
|
|
kind: .lint,
|
|
minSwiftVersion: .fiveDotOne,
|
|
nonTriggeringExamples: AccessibilityTraitForButtonRuleExamples.nonTriggeringExamples,
|
|
triggeringExamples: AccessibilityTraitForButtonRuleExamples.triggeringExamples
|
|
)
|
|
}
|
|
|
|
private extension AccessibilityTraitForButtonRule {
|
|
final class Visitor: ViolationsSyntaxVisitor<ConfigurationType> {
|
|
private var isInViewStruct = false
|
|
|
|
override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind {
|
|
isInViewStruct = node.isViewStruct
|
|
return .visitChildren
|
|
}
|
|
|
|
override func visitPost(_: StructDeclSyntax) {
|
|
isInViewStruct = false
|
|
}
|
|
|
|
override func visitPost(_ node: FunctionCallExprSyntax) {
|
|
guard isInViewStruct, node.isSingleTapGestureModifier() else {
|
|
return
|
|
}
|
|
|
|
// The 'node' is the tap gesture modifier itself.
|
|
// Check if this node, any preceding modifiers in the chain, or an encapsulating Button/Link provide
|
|
// exemption.
|
|
if !AccessibilityButtonTraitDeterminator.isExempt(tapGestureNode: node) {
|
|
violations.append(
|
|
ReasonedRuleViolation(
|
|
// Position of .onTapGesture etc.
|
|
position: node.calledExpression.positionAfterSkippingLeadingTrivia,
|
|
reason: AccessibilityTraitForButtonRule.description.description,
|
|
severity: configuration.severity
|
|
)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct AccessibilityButtonTraitDeterminator {
|
|
static let maxSearchDepth = 20 // Limit for traversing up the syntax tree
|
|
|
|
static func isExempt(tapGestureNode: FunctionCallExprSyntax) -> Bool {
|
|
// 1. Check if accessibility traits are present anywhere in the same modifier chain
|
|
if hasAccessibilityTraitsInChain(tapGestureNode: tapGestureNode) {
|
|
return true
|
|
}
|
|
|
|
// 2. Check if the view (to which the gesture is applied) is part of an inherently exempting container
|
|
return isInsideInherentlyExemptingContainer(startingFrom: tapGestureNode)
|
|
}
|
|
|
|
private static func hasAccessibilityTraitsInChain(tapGestureNode: FunctionCallExprSyntax) -> Bool {
|
|
// Check both directions: before the tap gesture (backwards in chain) and after (ancestors in tree)
|
|
|
|
// 1. Check backwards in the modifier chain (modifiers applied before the tap gesture)
|
|
if hasAccessibilityTraitsBackwards(from: tapGestureNode) {
|
|
return true
|
|
}
|
|
|
|
// 2. Check forwards by looking at parent nodes (modifiers applied after the tap gesture)
|
|
return hasAccessibilityTraitsForwards(from: tapGestureNode)
|
|
}
|
|
|
|
private static func hasAccessibilityTraitsBackwards(from tapGestureNode: FunctionCallExprSyntax) -> Bool {
|
|
var current: ExprSyntax? = ExprSyntax(tapGestureNode)
|
|
var depth = 0
|
|
|
|
while let currentExpr = current, depth < maxSearchDepth {
|
|
defer { depth += 1 }
|
|
|
|
if let funcCall = currentExpr.as(FunctionCallExprSyntax.self) {
|
|
// Check if this modifier provides accessibility traits
|
|
if funcCall.providesButtonOrLinkTrait() || funcCall.isAccessibilityHiddenTrue() {
|
|
return true
|
|
}
|
|
|
|
// Move to the previous modifier in the chain (the base of the member access)
|
|
if let memberAccess = funcCall.calledExpression.as(MemberAccessExprSyntax.self) {
|
|
current = memberAccess.base
|
|
} else {
|
|
// Reached the end of the chain (e.g., Text(...))
|
|
break
|
|
}
|
|
} else {
|
|
break
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
private static func hasAccessibilityTraitsForwards(from tapGestureNode: FunctionCallExprSyntax) -> Bool {
|
|
// Look at parent nodes to see if accessibility traits are applied after the tap gesture
|
|
var currentNode: Syntax? = Syntax(tapGestureNode).parent
|
|
var depth = 0
|
|
|
|
while let node = currentNode, depth < maxSearchDepth {
|
|
defer {
|
|
currentNode = node.parent
|
|
depth += 1
|
|
}
|
|
|
|
// Check if this parent node is a function call with accessibility traits
|
|
if let funcCall = node.as(FunctionCallExprSyntax.self) {
|
|
if funcCall.providesButtonOrLinkTrait() || funcCall.isAccessibilityHiddenTrue() {
|
|
return true
|
|
}
|
|
}
|
|
|
|
// Stop at certain boundaries
|
|
if node.is(StmtSyntax.self) || node.is(StructDeclSyntax.self) {
|
|
break
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
private static func isInsideInherentlyExemptingContainer(startingFrom node: FunctionCallExprSyntax) -> Bool {
|
|
var currentNode: Syntax? = Syntax(node)
|
|
var depth = 0
|
|
|
|
while let currentSyntaxNode = currentNode, depth < maxSearchDepth {
|
|
defer {
|
|
currentNode = currentSyntaxNode.parent
|
|
depth += 1
|
|
}
|
|
|
|
if let funcCall = currentSyntaxNode.as(FunctionCallExprSyntax.self),
|
|
let identifier = funcCall.calledExpression.as(DeclReferenceExprSyntax.self)?.baseName.text,
|
|
["Button", "Link"].contains(identifier) {
|
|
return true
|
|
}
|
|
|
|
// Stop if we reach a new View declaration or similar boundary
|
|
if currentSyntaxNode.is(StructDeclSyntax.self) ||
|
|
currentSyntaxNode.is(ClassDeclSyntax.self) ||
|
|
currentSyntaxNode.is(EnumDeclSyntax.self) {
|
|
break
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
/// Helper to check if an expression (part of a gesture modifier argument) is TapGesture(count: 1) or TapGesture()
|
|
fileprivate static func isSingleTapGestureInstance(expression: ExprSyntax) -> Bool {
|
|
var currentExpr: ExprSyntax? = expression
|
|
|
|
// Traverse down if it's a chain of gesture modifiers like .onEnded to find the base gesture.
|
|
while let memberCall = currentExpr?.as(FunctionCallExprSyntax.self), // e.g. TapGesture().onEnded()
|
|
let memberAccess = memberCall.calledExpression.as(MemberAccessExprSyntax.self),
|
|
memberAccess.base != nil { // Ensure it's a chain like x.method()
|
|
currentExpr = memberAccess.base
|
|
}
|
|
|
|
guard let gestureCall = currentExpr?.as(FunctionCallExprSyntax.self),
|
|
let gestureName = gestureCall.calledExpression.as(DeclReferenceExprSyntax.self)?.baseName.text,
|
|
gestureName == "TapGesture" else {
|
|
return false
|
|
}
|
|
|
|
// Check count argument: TapGesture() or TapGesture(count: 1)
|
|
if gestureCall.arguments.isEmpty { return true } // Default count is 1
|
|
|
|
if let countArg = gestureCall.arguments.first(where: { $0.label?.text == "count" }) {
|
|
return countArg.expression.as(IntegerLiteralExprSyntax.self)?.literal.text == "1"
|
|
}
|
|
|
|
// If 'count' is not specified, it defaults to 1.
|
|
return !gestureCall.arguments.contains(where: { $0.label?.text == "count" })
|
|
}
|
|
}
|
|
|
|
private extension StructDeclSyntax {
|
|
var isViewStruct: Bool {
|
|
guard let inheritanceClause else { return false }
|
|
return inheritanceClause.inheritedTypes.contains { inheritedType in
|
|
inheritedType.type.as(IdentifierTypeSyntax.self)?.name.text == "View"
|
|
}
|
|
}
|
|
}
|
|
|
|
private extension FunctionCallExprSyntax {
|
|
func isSingleTapGestureModifier() -> Bool {
|
|
guard let calledExpr = calledExpression.as(MemberAccessExprSyntax.self) else { return false }
|
|
let name = calledExpr.declName.baseName.text
|
|
|
|
if name == "onTapGesture" {
|
|
if arguments.isEmpty { return true } // Default count is 1
|
|
if let countArg = arguments.first(where: { $0.label?.text == "count" }) {
|
|
return countArg.expression.as(IntegerLiteralExprSyntax.self)?.literal.text == "1"
|
|
}
|
|
// If 'count' is not specified, it defaults to 1 (other args like 'perform' might be present)
|
|
return !arguments.contains(where: { $0.label?.text == "count" })
|
|
}
|
|
|
|
if ["gesture", "simultaneousGesture", "highPriorityGesture"].contains(name) {
|
|
guard let firstArgExpression = arguments.first?.expression else { return false }
|
|
return AccessibilityButtonTraitDeterminator.isSingleTapGestureInstance(expression: firstArgExpression)
|
|
}
|
|
return false
|
|
}
|
|
|
|
func providesButtonOrLinkTrait() -> Bool {
|
|
guard let calledExpr = calledExpression.as(MemberAccessExprSyntax.self) else { return false }
|
|
let name = calledExpr.declName.baseName.text
|
|
|
|
if name == "accessibilityAddTraits" {
|
|
guard let firstArgExpr = arguments.first?.expression else { return false }
|
|
return Self.expressionContainsButtonOrLinkTrait(firstArgExpr)
|
|
}
|
|
|
|
if name == "accessibility" {
|
|
guard let addTraitsArg = arguments.first(where: { $0.label?.text == "addTraits" }) else { return false }
|
|
return Self.expressionContainsButtonOrLinkTrait(addTraitsArg.expression)
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
private static func expressionContainsButtonOrLinkTrait(_ expression: ExprSyntax) -> Bool {
|
|
if let memberAccess = expression.as(MemberAccessExprSyntax.self) {
|
|
let traitName = memberAccess.declName.baseName.text
|
|
return traitName == "isButton" || traitName == "isLink"
|
|
}
|
|
if let arrayExpr = expression.as(ArrayExprSyntax.self) {
|
|
return arrayExpr.elements.contains { element in
|
|
self.expressionContainsButtonOrLinkTrait(element.expression)
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func isAccessibilityHiddenTrue() -> Bool {
|
|
guard let calledExpr = calledExpression.as(MemberAccessExprSyntax.self) else { return false }
|
|
let name = calledExpr.declName.baseName.text
|
|
|
|
if name == "accessibilityHidden" {
|
|
return arguments.first?.expression.as(BooleanLiteralExprSyntax.self)?.literal.tokenKind == .keyword(.true)
|
|
}
|
|
|
|
if name == "accessibility" {
|
|
guard let hiddenArg = arguments.first(where: { $0.label?.text == "hidden" }) else { return false }
|
|
return hiddenArg.expression.as(BooleanLiteralExprSyntax.self)?.literal.tokenKind == .keyword(.true)
|
|
}
|
|
|
|
return false
|
|
}
|
|
}
|