mirror of
https://github.com/realm/SwiftLint.git
synced 2026-05-07 20:12:49 +00:00
210 lines
8.6 KiB
Swift
210 lines
8.6 KiB
Swift
import SwiftSyntax
|
|
|
|
/// Require that any state properties in SwiftUI be declared as private
|
|
///
|
|
/// State and StateObject properties should only be accessible
|
|
/// from inside a SwiftUI App, View, or Scene, or from methods called by it.
|
|
///
|
|
/// Per Apple's documentation on [State](https://developer.apple.com/documentation/swiftui/state)
|
|
/// and [StateObject](https://developer.apple.com/documentation/swiftui/stateobject)
|
|
///
|
|
/// Declare state and state objects as private to prevent setting them from a memberwise initializer,
|
|
/// which can conflict with the storage management that SwiftUI provides:
|
|
@SwiftSyntaxRule(explicitRewriter: true, optIn: true)
|
|
struct PrivateSwiftUIStatePropertyRule: Rule {
|
|
var configuration = SeverityConfiguration<Self>(.warning)
|
|
|
|
static let description = RuleDescription(
|
|
identifier: "private_swiftui_state",
|
|
name: "Private SwiftUI State Properties",
|
|
description: "SwiftUI state properties should be private",
|
|
kind: .lint,
|
|
nonTriggeringExamples: PrivateSwiftUIStatePropertyRuleExamples.nonTriggeringExamples,
|
|
triggeringExamples: PrivateSwiftUIStatePropertyRuleExamples.triggeringExamples,
|
|
corrections: PrivateSwiftUIStatePropertyRuleExamples.corrections
|
|
)
|
|
}
|
|
|
|
private extension PrivateSwiftUIStatePropertyRule {
|
|
final class Visitor: ViolationsSyntaxVisitor<ConfigurationType> {
|
|
override var skippableDeclarations: [any DeclSyntaxProtocol.Type] {
|
|
[ProtocolDeclSyntax.self]
|
|
}
|
|
|
|
/// LIFO stack that stores if a type conforms to SwiftUI protocols.
|
|
/// `true` indicates that SwiftUI state properties should be
|
|
/// checked in the scope of the last entered declaration.
|
|
private var swiftUITypeScopes = Stack<Bool>()
|
|
|
|
override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind {
|
|
swiftUITypeScopes.push(node.inheritanceClause.conformsToApplicableSwiftUIProtocol)
|
|
return .visitChildren
|
|
}
|
|
|
|
override func visitPost(_: ClassDeclSyntax) {
|
|
swiftUITypeScopes.pop()
|
|
}
|
|
|
|
override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind {
|
|
swiftUITypeScopes.push(node.inheritanceClause.conformsToApplicableSwiftUIProtocol)
|
|
return .visitChildren
|
|
}
|
|
|
|
override func visitPost(_: StructDeclSyntax) {
|
|
swiftUITypeScopes.pop()
|
|
}
|
|
|
|
override func visit(_ node: ActorDeclSyntax) -> SyntaxVisitorContinueKind {
|
|
swiftUITypeScopes.push(node.inheritanceClause.conformsToApplicableSwiftUIProtocol)
|
|
return .visitChildren
|
|
}
|
|
|
|
override func visitPost(_: ActorDeclSyntax) {
|
|
swiftUITypeScopes.pop()
|
|
}
|
|
|
|
override func visitPost(_ node: VariableDeclSyntax) {
|
|
guard node.parent?.is(MemberBlockItemSyntax.self) == true,
|
|
swiftUITypeScopes.peek() ?? false,
|
|
node.containsSwiftUIStateAccessLevelViolation
|
|
else {
|
|
return
|
|
}
|
|
|
|
if let firstAccessLevelModifier = node.modifiers.accessLevelModifier {
|
|
violations.append(firstAccessLevelModifier.positionAfterSkippingLeadingTrivia)
|
|
} else {
|
|
violations.append(node.bindingSpecifier.positionAfterSkippingLeadingTrivia)
|
|
}
|
|
}
|
|
}
|
|
|
|
final class Rewriter: ViolationsSyntaxRewriter<ConfigurationType> {
|
|
/// LIFO stack that stores if a type conforms to SwiftUI protocols.
|
|
/// `true` indicates that SwiftUI state properties should be
|
|
/// checked in the scope of the last entered declaration.
|
|
private var swiftUITypeScopes = Stack<Bool>()
|
|
|
|
override func visit(_ node: ClassDeclSyntax) -> DeclSyntax {
|
|
swiftUITypeScopes.push(node.inheritanceClause.conformsToApplicableSwiftUIProtocol)
|
|
return super.visit(node)
|
|
}
|
|
|
|
override func visit(_ node: StructDeclSyntax) -> DeclSyntax {
|
|
swiftUITypeScopes.push(node.inheritanceClause.conformsToApplicableSwiftUIProtocol)
|
|
return super.visit(node)
|
|
}
|
|
|
|
override func visit(_ node: ActorDeclSyntax) -> DeclSyntax {
|
|
swiftUITypeScopes.push(node.inheritanceClause.conformsToApplicableSwiftUIProtocol)
|
|
return super.visit(node)
|
|
}
|
|
|
|
override func visitPost(_ node: Syntax) {
|
|
if node.is(ClassDeclSyntax.self) ||
|
|
node.is(StructDeclSyntax.self) ||
|
|
node.is(ActorDeclSyntax.self) {
|
|
swiftUITypeScopes.pop()
|
|
}
|
|
}
|
|
|
|
override func visit(_ node: VariableDeclSyntax) -> DeclSyntax {
|
|
guard
|
|
node.parent?.is(MemberBlockItemSyntax.self) == true,
|
|
swiftUITypeScopes.peek() ?? false,
|
|
node.containsSwiftUIStateAccessLevelViolation
|
|
else {
|
|
return DeclSyntax(node)
|
|
}
|
|
|
|
numberOfCorrections += 1
|
|
|
|
// If there are no modifiers present on the current syntax node,
|
|
// then we should retain the binding specifier's leading trivia
|
|
// by appending it to our inserted private access level modifier
|
|
if node.modifiers.isEmpty {
|
|
// Extract the leading trivia from the binding specifier and apply it to the private modifier
|
|
let privateModifier = DeclModifierSyntax(
|
|
leadingTrivia: node.bindingSpecifier.leadingTrivia,
|
|
name: .keyword(.private),
|
|
trailingTrivia: .space
|
|
)
|
|
|
|
let bindingSpecifier = node.bindingSpecifier.with(\.leadingTrivia, [])
|
|
let newNode = node
|
|
.with(\.modifiers, [privateModifier])
|
|
.with(\.bindingSpecifier, bindingSpecifier)
|
|
return DeclSyntax(newNode)
|
|
}
|
|
|
|
// If any existing, violating access modifiers are present
|
|
// then we should extract their trivia and
|
|
// append it to the inserted private access level modifier
|
|
let existingAccessLevelModifiers = node.modifiers.filter { $0.asAccessLevelModifier != nil }
|
|
// Remove any existing access control modifiers, but preserve any of their leading and trailing trivia
|
|
// Existing trivia will be appended to the rewritten access modifier
|
|
let previousAccessModifierLeadingTrivia = existingAccessLevelModifiers
|
|
.map(\.leadingTrivia)
|
|
.reduce(Trivia(pieces: [])) { partialResult, trivia in
|
|
partialResult.merging(trivia)
|
|
}
|
|
|
|
let previousAccessModifierTrailingTrivia = existingAccessLevelModifiers
|
|
.map(\.trailingTrivia)
|
|
.reduce(Trivia(pieces: [])) { partialResult, trivia in
|
|
partialResult.merging(trivia)
|
|
}
|
|
|
|
let filteredModifiers = node.modifiers.filter { $0.asAccessLevelModifier == nil }
|
|
// Extract the leading trivia from the binding specifier and apply it to the private modifier
|
|
let privateModifier = DeclModifierSyntax(
|
|
leadingTrivia: previousAccessModifierLeadingTrivia,
|
|
name: .keyword(.private),
|
|
trailingTrivia: previousAccessModifierTrailingTrivia.merging(.space)
|
|
)
|
|
|
|
return DeclSyntax(
|
|
node.with(\.modifiers, [privateModifier] + filteredModifiers)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private extension VariableDeclSyntax {
|
|
var containsSwiftUIStateAccessLevelViolation: Bool {
|
|
attributes.hasStateAttribute && !modifiers.containsPrivateOrFileprivate()
|
|
}
|
|
}
|
|
|
|
private extension InheritanceClauseSyntax? {
|
|
static let applicableSwiftUIProtocols: Set<String> = ["View", "App", "Scene"]
|
|
|
|
var conformsToApplicableSwiftUIProtocol: Bool {
|
|
self?.inheritedTypes.containsInheritedType(inheritedTypes: Self.applicableSwiftUIProtocols) ?? false
|
|
}
|
|
}
|
|
|
|
private extension InheritedTypeListSyntax {
|
|
func containsInheritedType(inheritedTypes: Set<String>) -> Bool {
|
|
contains {
|
|
guard let simpleType = $0.type.as(IdentifierTypeSyntax.self) else { return false }
|
|
|
|
return inheritedTypes.contains(simpleType.name.text)
|
|
}
|
|
}
|
|
}
|
|
|
|
private extension AttributeListSyntax {
|
|
/// Returns `true` if the attribute's identifier is equal to `State` or `StateObject`
|
|
var hasStateAttribute: Bool {
|
|
contains { attr in
|
|
guard let stateAttr = attr.as(AttributeSyntax.self),
|
|
let identifier = stateAttr.attributeName.as(IdentifierTypeSyntax.self) else {
|
|
return false
|
|
}
|
|
|
|
return identifier.name.text == "State" || identifier.name.text == "StateObject"
|
|
}
|
|
}
|
|
}
|