diff --git a/CHANGELOG.md b/CHANGELOG.md index 806163ee4c..f31881f734 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -109,6 +109,10 @@ #### Enhancements +* Add `Accessibility Font Size` rule to warn if a SwiftUI Text + has a fixed font size. + [MartijnAmbagtsheer](https://github.com/MartijnAmbagtsheer) + * Add `only` configuration option to `todo` rule which allows to specify whether the rule shall trigger on `TODO`s, `FIXME`s or both. [gibachan](https://github.com/gibachan) diff --git a/Source/SwiftLintBuiltInRules/Models/BuiltInRules.swift b/Source/SwiftLintBuiltInRules/Models/BuiltInRules.swift index 4615c17caf..2f8e437745 100644 --- a/Source/SwiftLintBuiltInRules/Models/BuiltInRules.swift +++ b/Source/SwiftLintBuiltInRules/Models/BuiltInRules.swift @@ -3,6 +3,7 @@ /// The rule list containing all available rules built into SwiftLint. public let builtInRules: [any Rule.Type] = [ + AccessibilityFontSizeRule.self, AccessibilityLabelForImageRule.self, AccessibilityTraitForButtonRule.self, AnonymousArgumentInMultilineClosureRule.self, diff --git a/Source/SwiftLintBuiltInRules/Rules/Lint/AccessibilityFontSizeRule.swift b/Source/SwiftLintBuiltInRules/Rules/Lint/AccessibilityFontSizeRule.swift new file mode 100644 index 0000000000..5ddb420dc7 --- /dev/null +++ b/Source/SwiftLintBuiltInRules/Rules/Lint/AccessibilityFontSizeRule.swift @@ -0,0 +1,139 @@ +import SourceKittenFramework + +struct AccessibilityFontSizeRule: ASTRule, OptInRule { + var configuration = SeverityConfiguration(.warning) + + static let description = RuleDescription( + identifier: "accessibility_font_size", + name: "Accessibility Font Size", + description: "Fonts may not have a fixed size", + kind: .lint, + minSwiftVersion: .fiveDotOne, + nonTriggeringExamples: AccessibilityFontSizeRuleExamples.nonTriggeringExamples, + triggeringExamples: AccessibilityFontSizeRuleExamples.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")) || kind == .extension, + dictionary.substructure.isNotEmpty else { + return [] + } + + return findFontViolations(file: file, substructure: dictionary.substructure) + } + + /// Recursively check a file for font violations, and return all such violations. + private func findFontViolations(file: SwiftLintFile, substructure: [SourceKittenDictionary]) -> [StyleViolation] { + var violations = [StyleViolation]() + for dictionary in substructure { + guard let offset: ByteCount = dictionary.offset else { + continue + } + + guard dictionary.isFontModifier(in: file) else { + if dictionary.substructure.isNotEmpty { + violations.append( + contentsOf: findFontViolations( + file: file, + substructure: dictionary.substructure + ) + ) + } + + continue + } + + if checkForViolations(dictionaries: [dictionary], in: file) { + violations.append( + StyleViolation( + ruleDescription: Self.description, + severity: configuration.severity, + location: Location(file: file, byteOffset: offset) + ) + ) + } + } + + return violations + } + + private func checkForViolations(dictionaries: [SourceKittenDictionary], in file: SwiftLintFile) -> Bool { + for dictionary in dictionaries { + if dictionary.hasSystemFontModifier(in: file) || dictionary.hasCustomFontModifierWithFixedSize(in: file) { + return true + } else if dictionary.substructure.isNotEmpty { + if checkForViolations(dictionaries: dictionary.substructure, in: file) { + return true + } + } + } + + return false + } +} + +// MARK: SourceKittenDictionary extensions + +private extension SourceKittenDictionary { + /// Whether or not the dictionary represents a SwiftUI Text. + /// Currently only accounts for SwiftUI text literals and not instance variables. + func isFontModifier(in file: SwiftLintFile) -> Bool { + // Text literals will be reported as calls to the initializer. + guard expressionKind == .call else { + return false + } + + if hasModifier( + anyOf: [ + SwiftUIModifier( + name: ".font", + arguments: [] + ) + ], + in: file + ) { + return true + } + + return substructure.contains(where: { $0.isFontModifier(in: file) }) + } + + func hasCustomFontModifierWithFixedSize(in file: SwiftLintFile) -> Bool { + return hasModifier( + anyOf: [ + SwiftUIModifier( + name: ".custom", + arguments: [ + .init( + name: "fixedSize", + values: [], + matchType: .substring) + ] + ) + ], + in: file + ) + } + + /// Whether or not the dictionary represents a SwiftUI View with an `font(.system())` modifier. + func hasSystemFontModifier(in file: SwiftLintFile) -> Bool { + return hasModifier( + anyOf: [ + SwiftUIModifier( + name: "system", + arguments: [ + .init( + name: "", + values: ["size"], + matchType: .substring) + ] + ) + ], + in: file + ) + } +} diff --git a/Source/SwiftLintBuiltInRules/Rules/Lint/AccessibilityFontSizeRuleExamples.swift b/Source/SwiftLintBuiltInRules/Rules/Lint/AccessibilityFontSizeRuleExamples.swift new file mode 100644 index 0000000000..cb50710fd2 --- /dev/null +++ b/Source/SwiftLintBuiltInRules/Rules/Lint/AccessibilityFontSizeRuleExamples.swift @@ -0,0 +1,155 @@ +internal struct AccessibilityFontSizeRuleExamples { + static let nonTriggeringExamples = [ + Example(""" + struct TestView: View { + var body: some View { + Text("Hello World!") + } + } + """), + Example(""" + struct TestView: View { + var body: some View { + Text("Hello World!") + .font(.system(.largeTitle)) + } + } + """), + Example(""" + struct TestView: View { + var body: some View { + TextField("Username", text: .constant("")) + .font(.system(.largeTitle)) + } + } + """), + Example(""" + struct TestView: View { + var body: some View { + SecureField("Password", text: .constant("")) + .font(.system(.largeTitle)) + } + } + """), + Example(""" + struct TestView: View { + var body: some View { + Button("Login") {} + .font(.system(.largeTitle)) + } + } + """) + ] + + static let triggeringExamples = [ + Example(""" + struct TestView: View { + var body: some View { + ↓Text("Hello World!") + .font(.system(size: 20)) + } + } + """), + Example(""" + struct TestView: View { + var body: some View { + ↓Text("Hello World!") + .italic() + .font(.system(size: 20)) + } + } + """), + Example(""" + struct TestView: View { + var body: some View { + ↓TextField("Username", text: .constant("")) + .font(.system(size: 15)) + } + } + """), + Example(""" + struct TestView: View { + var body: some View { + ↓SecureField("Password", text: .constant("")) + .font(.system(size: 15)) + } + } + """), + Example(""" + struct TestView: View { + var body: some View { + ↓Button("Login") {} + .font(.system(size: 15)) + } + } + """), + Example(""" + struct TestView: View { + var body: some View { + ↓Text("Hello World!") + .font(.custom("Havana", fixedSize: 16)) + } + } + """), + Example(""" + struct TestView: View { + var body: some View { + ↓Text("Hello World!") + .italic() + .font(.custom("Havana", fixedSize: 16)) + } + } + """), + Example(""" + struct TestView: View { + var body: some View { + ↓TextField("Username", text: .constant("")) + .font(.custom("Havana", fixedSize: 16)) + } + } + """), + Example(""" + struct TestView: View { + var body: some View { + ↓TextField("Username", text: .constant("")) + .italic() + .font(.custom("Havana", fixedSize: 16)) + } + } + """), + Example(""" + struct TestView: View { + var body: some View { + ↓SecureField("Password", text: .constant("")) + .font(.custom("Havana", fixedSize: 16)) + } + } + """), + Example(""" + struct TestView: View { + var body: some View { + ↓SecureField("Password", text: .constant("")) + .italic() + .font(.custom("Havana", fixedSize: 16)) + } + } + """), + Example(""" + struct TestView: View { + var body: some View { + ↓Button("Login") {} + .font(.custom("Havana", fixedSize: 16)) + } + } + """), + Example(""" + struct TestView: View { + var body: some View { + ↓Button("Login") {} + .italic() + .font(.custom("Havana", fixedSize: 16)) + } + } + """) + ] +} diff --git a/Tests/GeneratedTests/GeneratedTests.swift b/Tests/GeneratedTests/GeneratedTests.swift index 078c6f614e..0ba9b8a129 100644 --- a/Tests/GeneratedTests/GeneratedTests.swift +++ b/Tests/GeneratedTests/GeneratedTests.swift @@ -8,6 +8,12 @@ import SwiftLintTestHelpers // swiftlint:disable:next blanket_disable_command // swiftlint:disable file_length single_test_class type_name +class AccessibilityFontSizeRuleGeneratedTests: SwiftLintTestCase { + func testWithDefaultConfiguration() { + verifyRule(AccessibilityFontSizeRule.description) + } +} + class AccessibilityLabelForImageRuleGeneratedTests: SwiftLintTestCase { func testWithDefaultConfiguration() { verifyRule(AccessibilityLabelForImageRule.description)