188 lines
7.7 KiB
Swift
188 lines
7.7 KiB
Swift
import SourceKittenFramework
|
|
|
|
/// Struct to represent SwiftUI ViewModifiers for the purpose of finding modifiers in a substructure.
|
|
struct SwiftUIModifier {
|
|
/// Name of the modifier.
|
|
let name: String
|
|
|
|
/// List of arguments to check for in the modifier.
|
|
let arguments: [Argument]
|
|
|
|
struct Argument {
|
|
/// Name of the argument we want to find. For single unnamed arguments, use the empty string.
|
|
let name: String
|
|
|
|
/// Whether or not the argument is required. If the argument is present, value checks are enforced.
|
|
/// Allows for better handling of modifiers with default values for certain arguments where we want
|
|
/// to ensure that the default value is used.
|
|
let required: Bool
|
|
|
|
/// List of possible values for the argument. Typically should just be a list with a single element,
|
|
/// but allows for the flexibility of checking for multiple possible values. To only check for the presence
|
|
/// of the modifier and not enforce any certain values, pass an empty array. All values are parsed as
|
|
/// Strings; for other types (boolean, numeric, optional, etc) types you can check for "true", "5", "nil", etc.
|
|
let values: [String]
|
|
|
|
/// Success criteria used for matching values (prefix, suffix, substring, exact match, or none).
|
|
let matchType: MatchType
|
|
|
|
init(name: String, required: Bool = true, values: [String], matchType: MatchType = .exactMatch) {
|
|
self.name = name
|
|
self.required = required
|
|
self.values = values
|
|
self.matchType = matchType
|
|
}
|
|
}
|
|
|
|
enum MatchType {
|
|
case prefix, suffix, substring, exactMatch
|
|
|
|
/// Compares the parsed argument value to a target value for the given match type
|
|
/// and returns true is a match is found.
|
|
func matches(argumentValue: String, targetValue: String) -> Bool {
|
|
switch self {
|
|
case .prefix:
|
|
return argumentValue.hasPrefix(targetValue)
|
|
case .suffix:
|
|
return argumentValue.hasSuffix(targetValue)
|
|
case .substring:
|
|
return argumentValue.contains(targetValue)
|
|
case .exactMatch:
|
|
return argumentValue == targetValue
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Extensions for recursively checking SwiftUI code for certain modifiers.
|
|
extension SourceKittenDictionary {
|
|
/// Call on a SwiftUI View to recursively check the substructure for a certain modifier with certain arguments.
|
|
/// - Parameters:
|
|
/// - modifiers: A list of `SwiftUIModifier` structs to check for in the view's substructure.
|
|
/// In most cases, this can just be a single modifier, but since some modifiers have
|
|
/// multiple versions, this enables checking for any modifier from the list.
|
|
/// - file: The SwiftLintFile object for the current file, used to extract argument values.
|
|
/// - Returns: A boolean value representing whether or not the given modifier with the specified
|
|
/// arguments appears in the view's substructure.
|
|
func hasModifier(anyOf modifiers: [SwiftUIModifier], in file: SwiftLintFile) -> Bool {
|
|
// SwiftUI ViewModifiers are treated as `call` expressions, and we make sure we can get the expression's name.
|
|
guard expressionKind == .call, let name else {
|
|
return false
|
|
}
|
|
|
|
// If any modifier from the list matches, return true.
|
|
for modifier in modifiers {
|
|
// Check for the given modifier name
|
|
guard name.hasSuffix(modifier.name) else {
|
|
continue
|
|
}
|
|
|
|
// Check arguments.
|
|
var matchesArgs = true
|
|
for argument in modifier.arguments {
|
|
var foundArg = false
|
|
var argValue: String?
|
|
|
|
// Check for single unnamed argument.
|
|
if argument.name.isEmpty {
|
|
foundArg = true
|
|
argValue = getSingleUnnamedArgumentValue(in: file)
|
|
} else if let parsedArgument = enclosedArguments.first(where: { $0.name == argument.name }) {
|
|
foundArg = true
|
|
argValue = parsedArgument.getArgumentValue(in: file)
|
|
}
|
|
|
|
// If argument is not required and we didn't find it, continue.
|
|
if !foundArg && !argument.required {
|
|
continue
|
|
}
|
|
|
|
// Otherwise, we must have found an argument with a non-nil value to continue.
|
|
guard foundArg, let argumentValue = argValue else {
|
|
matchesArgs = false
|
|
break
|
|
}
|
|
|
|
// Argument value can match any of the options given in the argument struct.
|
|
if argument.values.isEmpty || argument.values.contains(where: {
|
|
argument.matchType.matches(argumentValue: argumentValue, targetValue: $0)
|
|
}) {
|
|
// Found a match, continue to next argument.
|
|
continue
|
|
} else {
|
|
// Did not find a match, exit loop over arguments.
|
|
matchesArgs = false
|
|
break
|
|
}
|
|
}
|
|
|
|
// Return true if all arguments matched
|
|
if matchesArgs {
|
|
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
|
|
// --> Image("myImage").resizable
|
|
// --> Image
|
|
return substructure.contains(where: { $0.hasModifier(anyOf: modifiers, in: file) })
|
|
}
|
|
|
|
// MARK: Sample use cases of `hasModifier` that are used in multiple rules
|
|
|
|
/// Whether or not the dictionary represents a SwiftUI View with an `accesibilityHidden(true)`
|
|
/// or `accessibility(hidden: true)` modifier.
|
|
func hasAccessibilityHiddenModifier(in file: SwiftLintFile) -> Bool {
|
|
return hasModifier(
|
|
anyOf: [
|
|
SwiftUIModifier(
|
|
name: "accessibilityHidden",
|
|
arguments: [.init(name: "", values: ["true"])]
|
|
),
|
|
SwiftUIModifier(
|
|
name: "accessibility",
|
|
arguments: [.init(name: "hidden", values: ["true"])]
|
|
)
|
|
],
|
|
in: file
|
|
)
|
|
}
|
|
|
|
/// 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 {
|
|
return hasModifier(
|
|
anyOf: [
|
|
SwiftUIModifier(
|
|
name: "accessibilityElement",
|
|
arguments: [.init(name: "children", required: false, values: [".ignore"], matchType: .suffix)]
|
|
)
|
|
],
|
|
in: file
|
|
)
|
|
}
|
|
|
|
// MARK: Helpers to extract argument values
|
|
|
|
/// Helper to get the value of an argument.
|
|
func getArgumentValue(in file: SwiftLintFile) -> String? {
|
|
guard expressionKind == .argument, let 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 else {
|
|
return nil
|
|
}
|
|
|
|
return file.stringView.substringWithByteRange(bodyByteRange)
|
|
}
|
|
}
|