Files
SwiftFormat/Sources/Rules/RedundantViewBuilder.swift

131 lines
5.2 KiB
Swift

//
// RedundantViewBuilder.swift
// SwiftFormat
//
// Created by Miguel Jimenez on 2025-12-14.
// Copyright © 2024 Nick Lockwood. All rights reserved.
//
import Foundation
public extension FormatRule {
/// Remove redundant @ViewBuilder attributes
static let redundantViewBuilder = FormatRule(
help: "Remove redundant @ViewBuilder attribute when it's not needed."
) { formatter in
// Collect all @ViewBuilder attributes to remove first (to avoid re-entrancy issues)
var attributeIndicesToRemove = [Int]()
formatter.parseDeclarations().forEachRecursiveDeclaration { declaration in
guard let viewBuilderIndex = formatter.indexOfViewBuilderAttribute(for: declaration)
else { return }
// Never remove @ViewBuilder from protocol members, as conforming types
// rely on the implicit result builder being added
if formatter.isInsideProtocol(at: declaration.keywordIndex) {
return
}
let bodyScope: ClosedRange<Int>?
let isBodyMember: Bool
// Parse the declaration to get body scope and check if it's a body member
if declaration.keyword == "var" || declaration.keyword == "let",
let property = declaration.parsePropertyDeclaration()
{
bodyScope = property.body?.scopeRange
// A var named "body" is only the protocol body if it's on a View
// (ViewModifier.body must be a function, not a property)
isBodyMember = property.identifier == "body" && formatter.isViewType(declaration.parentType)
} else if declaration.keyword == "func",
let function = formatter.parseFunctionDeclaration(keywordIndex: declaration.keywordIndex)
{
bodyScope = function.bodyRange
// A func named "body" is only the protocol body if it's on a ViewModifier
// (View.body must be a property, not a function)
isBodyMember = function.name == "body" && formatter.isViewModifierType(declaration.parentType)
} else {
return
}
guard let bodyScope else { return }
// @ViewBuilder is redundant if:
// 1. It's the body protocol requirement of a View/ViewModifier, OR
// 2. The body contains only a single non-conditional expression
let isRedundant = isBodyMember
|| formatter.scopeBodyIsSingleNonConditionalExpression(at: bodyScope.lowerBound)
if isRedundant {
attributeIndicesToRemove.append(viewBuilderIndex)
}
}
// Remove the attributes in reverse order to not invalidate indices
for attributeIndex in attributeIndicesToRemove.reversed() {
formatter.removeViewBuilderAttribute(at: attributeIndex)
}
} examples: {
"""
```diff
struct MyView: View {
- @ViewBuilder
var body: some View {
Text("foo")
Text("bar")
}
- @ViewBuilder
var helper: some View {
VStack {
Text("baaz")
Text("quux")
}
}
// Not redundant - multiple top-level views
@ViewBuilder
var helper2: some View {
Text("foo")
Text("bar")
}
}
```
"""
}
}
extension Formatter {
/// Removes a @ViewBuilder attribute at the given index, including the trailing linebreak if on its own line
func removeViewBuilderAttribute(at attributeIndex: Int) {
var startIndex = attributeIndex
var endIndex = attributeIndex
// Check if there's a leading space (between another attribute and this one)
let hasLeadingSpace = attributeIndex > 0 && tokens[attributeIndex - 1].isSpace
let leadingSpaceIsAfterAttribute = hasLeadingSpace && attributeIndex > 1 && !tokens[attributeIndex - 2].isLinebreak
let nextNonSpaceIndex = index(of: .nonSpace, after: attributeIndex)
let hasTrailingLinebreak = nextNonSpaceIndex != nil && tokens[nextNonSpaceIndex!].isLinebreak
let hasTrailingSpace = attributeIndex + 1 < tokens.count && tokens[attributeIndex + 1].isSpace
if leadingSpaceIsAfterAttribute {
// Remove the space before @ViewBuilder (space between attributes)
startIndex = attributeIndex - 1
// Don't remove trailing linebreak - preserve the line structure
// Don't remove trailing space - it separates from the next token
} else if hasTrailingLinebreak, let nextIndex = nextNonSpaceIndex {
// @ViewBuilder is at the start of the line (possibly with indentation)
endIndex = nextIndex
// Also remove leading indentation
if hasLeadingSpace, attributeIndex > 1, tokens[attributeIndex - 2].isLinebreak {
startIndex = attributeIndex - 1
}
} else if hasTrailingSpace {
endIndex = attributeIndex + 1
}
removeTokens(in: startIndex ... endIndex)
}
}