mirror of
https://github.com/nicklockwood/SwiftFormat.git
synced 2026-06-16 10:34:34 +00:00
131 lines
5.2 KiB
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)
|
|
}
|
|
}
|