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(.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 { 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() 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 { /// 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() 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 = ["View", "ViewModifier", "App", "Scene"] var conformsToApplicableSwiftUIProtocol: Bool { self?.inheritedTypes.containsInheritedType(inheritedTypes: Self.applicableSwiftUIProtocols) ?? false } } private extension InheritedTypeListSyntax { func containsInheritedType(inheritedTypes: Set) -> 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" } } }