diff --git a/CHANGELOG.md b/CHANGELOG.md index 020b8889f7..116999a603 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,12 @@ * Migrate `vertical_whitespace` rule from SourceKit to SwiftSyntax for improved performance. [Matt Pennig](https://github.com/pennig) +* Fix false positives of `redundant_discardable_let` rule in `@ViewBuilder` functions, + `#Preview` macro bodies and preview providers when `ignore_swiftui_view_bodies` is + enabled. + [kaseken](https://github.com/kaseken) + [#6063](https://github.com/realm/SwiftLint/issues/6063) + ### Bug Fixes * Improved error reporting when SwiftLint exits, because of an invalid configuration file diff --git a/Source/SwiftLintBuiltInRules/Rules/Style/RedundantDiscardableLetRule.swift b/Source/SwiftLintBuiltInRules/Rules/Style/RedundantDiscardableLetRule.swift index d7d6f2a1f6..c2fd3b0554 100644 --- a/Source/SwiftLintBuiltInRules/Rules/Style/RedundantDiscardableLetRule.swift +++ b/Source/SwiftLintBuiltInRules/Rules/Style/RedundantDiscardableLetRule.swift @@ -22,6 +22,25 @@ struct RedundantDiscardableLetRule: Rule { return Text("Hello, World!") } """, configuration: ["ignore_swiftui_view_bodies": true]), + Example(""" + @ViewBuilder + func bar() -> some View { + let _ = foo() + Text("Hello, World!") + } + """, configuration: ["ignore_swiftui_view_bodies": true]), + Example(""" + #Preview { + let _ = foo() + Text("Hello, World!") + } + """, configuration: ["ignore_swiftui_view_bodies": true]), + Example(""" + static var previews: some View { + let _ = foo() + Text("Hello, World!") + } + """, configuration: ["ignore_swiftui_view_bodies": true]), ], triggeringExamples: [ Example("↓let _ = foo()"), @@ -32,10 +51,74 @@ struct RedundantDiscardableLetRule: Rule { Text("Hello, World!") } """), + Example(""" + @ViewBuilder + func bar() -> some View { + ↓let _ = foo() + return Text("Hello, World!") + } + """), + Example(""" + #Preview { + ↓let _ = foo() + return Text("Hello, World!") + } + """), + Example(""" + static var previews: some View { + ↓let _ = foo() + Text("Hello, World!") + } + """), + Example(""" + var notBody: some View { + ↓let _ = foo() + Text("Hello, World!") + } + """, configuration: ["ignore_swiftui_view_bodies": true], excludeFromDocumentation: true), + Example(""" + var body: some NotView { + ↓let _ = foo() + Text("Hello, World!") + } + """, configuration: ["ignore_swiftui_view_bodies": true], excludeFromDocumentation: true), ], corrections: [ Example("↓let _ = foo()"): Example("_ = foo()"), Example("if _ = foo() { ↓let _ = bar() }"): Example("if _ = foo() { _ = bar() }"), + Example(""" + var body: some View { + ↓let _ = foo() + Text("Hello, World!") + } + """): Example(""" + var body: some View { + _ = foo() + Text("Hello, World!") + } + """), + Example(""" + #Preview { + ↓let _ = foo() + return Text("Hello, World!") + } + """): Example(""" + #Preview { + _ = foo() + return Text("Hello, World!") + } + """), + Example(""" + var body: some View { + let _ = foo() + return Text("Hello, World!") + } + """, configuration: ["ignore_swiftui_view_bodies": true]): Example(""" + var body: some View { + let _ = foo() + return Text("Hello, World!") + } + """), ] ) } @@ -50,7 +133,7 @@ private extension RedundantDiscardableLetRule { private var codeBlockScopes = Stack() override func visit(_ node: AccessorBlockSyntax) -> SyntaxVisitorContinueKind { - codeBlockScopes.push(node.isViewBody ? .view : .normal) + codeBlockScopes.push(node.isViewBody || node.isPreviewProviderBody ? .view : .normal) return .visitChildren } @@ -58,8 +141,8 @@ private extension RedundantDiscardableLetRule { codeBlockScopes.pop() } - override func visit(_: CodeBlockSyntax) -> SyntaxVisitorContinueKind { - codeBlockScopes.push(.normal) + override func visit(_ node: CodeBlockSyntax) -> SyntaxVisitorContinueKind { + codeBlockScopes.push(node.isViewBuilderFunctionBody ? .view : .normal) return .visitChildren } @@ -67,6 +150,15 @@ private extension RedundantDiscardableLetRule { codeBlockScopes.pop() } + override func visit(_ node: ClosureExprSyntax) -> SyntaxVisitorContinueKind { + codeBlockScopes.push(node.isPreviewMacroBody ? .view : .normal) + return .visitChildren + } + + override func visitPost(_: ClosureExprSyntax) { + codeBlockScopes.pop() + } + override func visitPost(_ node: VariableDeclSyntax) { if codeBlockScopes.peek() != .view || !configuration.ignoreSwiftUIViewBodies, node.bindingSpecifier.tokenKind == .keyword(.let), @@ -94,10 +186,45 @@ private extension AccessorBlockSyntax { if let binding = parent?.as(PatternBindingSyntax.self), binding.pattern.as(IdentifierPatternSyntax.self)?.identifier.text == "body", let type = binding.typeAnnotation?.type.as(SomeOrAnyTypeSyntax.self) { - return type.someOrAnySpecifier.text == "some" - && type.constraint.as(IdentifierTypeSyntax.self)?.name.text == "View" - && binding.parent?.parent?.is(VariableDeclSyntax.self) == true + return type.isView && binding.parent?.parent?.is(VariableDeclSyntax.self) == true } return false } + + var isPreviewProviderBody: Bool { + guard let binding = parent?.as(PatternBindingSyntax.self), + binding.pattern.as(IdentifierPatternSyntax.self)?.identifier.text == "previews", + let bindingList = binding.parent?.as(PatternBindingListSyntax.self), + let variableDecl = bindingList.parent?.as(VariableDeclSyntax.self), + variableDecl.modifiers.contains(keyword: .static), + variableDecl.bindingSpecifier.tokenKind == .keyword(.var), + let type = binding.typeAnnotation?.type.as(SomeOrAnyTypeSyntax.self) else { + return false + } + + return type.isView + } +} + +private extension CodeBlockSyntax { + var isViewBuilderFunctionBody: Bool { + guard let functionDecl = parent?.as(FunctionDeclSyntax.self), + functionDecl.attributes.contains(attributeNamed: "ViewBuilder") else { + return false + } + return functionDecl.signature.returnClause?.type.as(SomeOrAnyTypeSyntax.self)?.isView ?? false + } +} + +private extension ClosureExprSyntax { + var isPreviewMacroBody: Bool { + parent?.as(MacroExpansionExprSyntax.self)?.macroName.text == "Preview" + } +} + +private extension SomeOrAnyTypeSyntax { + var isView: Bool { + someOrAnySpecifier.text == "some" && + constraint.as(IdentifierTypeSyntax.self)?.name.text == "View" + } }