Add preserve ifdef indent mode (#2269)

This commit is contained in:
David Roman
2025-11-15 11:21:49 +00:00
committed by Cal Stephens
parent d02ba73bb5
commit d3b707d7cc
6 changed files with 352 additions and 20 deletions
+1 -1
View File
@@ -1392,7 +1392,7 @@ Option | Description
`--tab-width` | The width of a tab character. Defaults to "unspecified"
`--smart-tabs` | Align code independently of tab-width: "enabled" (default) or "disabled"
`--indent-case` | Indent cases inside a switch statement: "true" or "false" (default)
`--ifdef` | #if statement indenting: "indent" (default), "no-indent" or "outdent"
`--ifdef` | #if statement indenting: "indent" (default), "no-indent", "preserve" or "outdent"
`--xcode-indentation` | Match Xcode indenting: "enabled" or "disabled" (default)
`--indent-strings` | Indent multiline strings: "true" or "false" (default)
+14 -2
View File
@@ -302,7 +302,8 @@ private struct Inference {
}
let ifdefIndent = OptionInferrer { formatter, options in
var indented = 0, notIndented = 0, outdented = 0
var indented = 0, notIndented = 0, outdented = 0, preserveCandidates = 0
formatter.forEach(.startOfScope("#if")) { i, _ in
if let indent = formatter.token(at: i - 1), case let .space(string) = indent,
!string.isEmpty
@@ -318,6 +319,11 @@ private struct Inference {
return
} else if innerString == string {
notIndented += 1
if let token = formatter.next(.nonSpaceOrCommentOrLinebreak, after: nextLineIndex),
case .operator(".", _) = token
{
preserveCandidates += 1
}
} else {
// Assume more indented, as less would be a mistake
indented += 1
@@ -360,7 +366,13 @@ private struct Inference {
// Error?
}
if notIndented > indented {
options.ifdefIndent = outdented > notIndented ? .outdent : .noIndent
if outdented > notIndented {
options.ifdefIndent = .outdent
} else if preserveCandidates > 0 {
options.ifdefIndent = .preserve
} else {
options.ifdefIndent = .noIndent
}
} else {
options.ifdefIndent = outdented > indented ? .outdent : .indent
}
+3
View File
@@ -35,6 +35,7 @@ import Foundation
public enum IndentMode: String, CaseIterable {
case indent
case noIndent = "no-indent"
case preserve
case outdent
public init?(rawValue: String) {
@@ -43,6 +44,8 @@ public enum IndentMode: String, CaseIterable {
self = .indent
case "no-indent", "noindent":
self = .noIndent
case "preserve":
self = .preserve
case "outdent":
self = .outdent
default:
+47 -17
View File
@@ -27,6 +27,15 @@ public extension FormatRule {
var indentCounts = [1]
var linewrapStack = [false]
var lineIndex = 0
var preserveIfdefDepth = 0
@discardableResult
func applyIndent(_ indent: String, at index: Int) -> Int {
if formatter.options.ifdefIndent == .preserve, preserveIfdefDepth > 0 {
return 0
}
return formatter.insertSpaceIfEnabled(indent, at: index)
}
if formatter.options.fragment,
let firstIndex = formatter.index(of: .nonSpaceOrLinebreak, after: -1),
@@ -103,12 +112,17 @@ public extension FormatRule {
}
switch formatter.options.ifdefIndent {
case .indent:
i += formatter.insertSpaceIfEnabled(indent, at: formatter.startOfLine(at: i))
i += applyIndent(indent, at: formatter.startOfLine(at: i))
indent += formatter.options.indent
case .noIndent:
i += formatter.insertSpaceIfEnabled(indent, at: formatter.startOfLine(at: i))
i += applyIndent(indent, at: formatter.startOfLine(at: i))
case .preserve:
indent = formatter.currentIndentForLine(at: i)
case .outdent:
i += formatter.insertSpaceIfEnabled("", at: formatter.startOfLine(at: i))
i += applyIndent("", at: formatter.startOfLine(at: i))
}
if formatter.options.ifdefIndent == .preserve {
preserveIfdefDepth += 1
}
case "{" where formatter.isFirstStackedClosureArgument(at: i):
guard var prevIndex = formatter.index(of: .nonSpace, before: i) else {
@@ -213,9 +227,11 @@ public extension FormatRule {
let start = formatter.startOfLine(at: i)
switch formatter.options.ifdefIndent {
case .indent, .noIndent:
i += formatter.insertSpaceIfEnabled(indent, at: start)
i += applyIndent(indent, at: start)
case .outdent:
i += formatter.insertSpaceIfEnabled("", at: start)
i += applyIndent("", at: start)
case .preserve:
break
}
case .keyword("@unknown") where scopeStack.last != .startOfScope("#if"):
var indent = indentStack[indentStack.count - 2]
@@ -224,7 +240,7 @@ public extension FormatRule {
}
let start = formatter.startOfLine(at: i)
let stringIndent = stringBodyIndentStack.last!
i += formatter.insertSpaceIfEnabled(stringIndent + indent, at: start)
i += applyIndent(stringIndent + indent, at: start)
case .keyword("in") where scopeStack.last == .startOfScope("{"):
if let startIndex = formatter.index(of: .startOfScope("{"), before: i),
formatter.index(of: .keyword("for"), in: startIndex + 1 ..< i) == nil,
@@ -330,7 +346,7 @@ public extension FormatRule {
}
if token == .endOfScope("#endif"), formatter.options.ifdefIndent == .outdent {
i += formatter.insertSpaceIfEnabled("", at: start)
i += applyIndent("", at: start)
} else {
var indent = indentStack.last ?? ""
if token.isSwitchCaseOrDefault,
@@ -339,7 +355,7 @@ public extension FormatRule {
indent += formatter.options.indent
}
let stringIndent = stringBodyIndentStack.last!
i += formatter.insertSpaceIfEnabled(stringIndent + indent, at: start)
i += applyIndent(stringIndent + indent, at: start)
}
} else if token == .endOfScope("#endif"), indentStack.count > 1 {
var indent = indentStack[indentStack.count - 2]
@@ -352,14 +368,19 @@ public extension FormatRule {
}
switch formatter.options.ifdefIndent {
case .indent, .noIndent:
i += formatter.insertSpaceIfEnabled(indent, at: formatter.startOfLine(at: i))
i += applyIndent(indent, at: formatter.startOfLine(at: i))
case .outdent:
i += formatter.insertSpaceIfEnabled("", at: formatter.startOfLine(at: i))
i += applyIndent("", at: formatter.startOfLine(at: i))
case .preserve:
break
}
if scopeStack.last == .startOfScope("#if") {
popScope()
}
}
if token == .endOfScope("#endif"), formatter.options.ifdefIndent == .preserve {
preserveIfdefDepth = max(preserveIfdefDepth - 1, 0)
}
}
switch token {
case .endOfScope("case"):
@@ -397,13 +418,13 @@ public extension FormatRule {
{
// Set indent for comment immediately before this line to match this line
if !formatter.isCommentedCode(at: startIndex + 1) {
formatter.insertSpaceIfEnabled(indent, at: startIndex + 1)
applyIndent(indent, at: startIndex + 1)
}
if case .endOfScope("*/") = prevToken,
var index = formatter.index(of: .startOfScope("/*"), after: startIndex)
{
while let linebreakIndex = formatter.index(of: .linebreak, after: index) {
formatter.insertSpaceIfEnabled(indent + " ", at: linebreakIndex + 1)
applyIndent(indent + " ", at: linebreakIndex + 1)
index = linebreakIndex
}
}
@@ -580,7 +601,9 @@ public extension FormatRule {
if lastToken.isEndOfScope {
indent = formatter.currentIndentForLine(at: lastNonSpaceOrLinebreakIndex)
}
if !lastToken.isEndOfScope || lastToken == .endOfScope("case") ||
if formatter.options.ifdefIndent == .preserve, preserveIfdefDepth > 0 {
// keep relative indentation unchanged
} else if !lastToken.isEndOfScope || lastToken == .endOfScope("case") ||
formatter.options.xcodeIndentation, ![
.endOfScope("}"), .endOfScope(")"),
].contains(lastToken)
@@ -602,6 +625,9 @@ public extension FormatRule {
guard !formatter.isCommentedCode(at: nextNonSpaceIndex) else {
break
}
if formatter.options.ifdefIndent == .preserve, preserveIfdefDepth > 0 {
break
}
// Apply indent
switch nextToken {
case .linebreak, .error, .keyword("#else"), .keyword("#elseif"), .endOfScope("#endif"),
@@ -630,19 +656,22 @@ public extension FormatRule {
{
break
}
formatter.insertSpaceIfEnabled(indent, at: i + 1)
applyIndent(indent, at: i + 1)
case .endOfScope, .keyword("@unknown"):
if let scope = scopeStack.last {
switch scope {
case .startOfScope("/*"), .startOfScope("#if"),
.keyword("#else"), .keyword("#elseif"),
.startOfScope where scope.isStringDelimiter:
formatter.insertSpaceIfEnabled(indent, at: i + 1)
applyIndent(indent, at: i + 1)
default:
break
}
}
default:
if formatter.options.ifdefIndent == .preserve, preserveIfdefDepth > 0 {
break
}
var lastIndex = lastNonSpaceOrLinebreakIndex > -1 ? lastNonSpaceOrLinebreakIndex : i
while formatter.token(at: lastIndex) == .endOfScope("#endif"),
let index = formatter.index(of: .startOfScope, before: lastIndex, if: {
@@ -682,7 +711,7 @@ public extension FormatRule {
break
}
}
formatter.insertSpaceIfEnabled(indent, at: i + 1)
applyIndent(indent, at: i + 1)
}
if linewrapped, formatter.shouldIndentNextLine(at: i) {
@@ -837,7 +866,7 @@ extension Formatter {
}
func isInIfdef(at i: Int, scopeStack: [Token]) -> Bool {
guard scopeStack.last == .startOfScope("#if") else {
guard scopeStack.contains(.startOfScope("#if")) else {
return false
}
var index = i - 1
@@ -854,6 +883,7 @@ extension Formatter {
return false
}
func isWrappedDeclaration(at i: Int) -> Bool {
guard let keywordIndex = indexOfLastSignificantKeyword(at: i, excluding: [
"where", "throws", "rethrows",
+18
View File
@@ -204,6 +204,24 @@ final class InferenceTests: XCTestCase {
XCTAssertEqual(options.ifdefIndent, output)
}
func testInferIfdefPreserve() {
let input = """
struct ContentView {
var body: some View {
Text("Example")
.frame(maxWidth: 200)
#if DEBUG
.font(.body)
#endif
.padding()
}
}
"""
let output = IndentMode.preserve
let options = inferFormatOptions(from: tokenize(input))
XCTAssertEqual(options.ifdefIndent, output)
}
func testInferIndentedIfdefOutdent() {
let input = "{\n {\n#if foo\n //foo\n#endif\n }\n}"
let output = IndentMode.outdent
+269
View File
@@ -4645,6 +4645,275 @@ final class IndentTests: XCTestCase {
testFormatting(for: input, rule: .indent, options: options)
}
func testIfDefPostfixMemberSyntaxPreserveKeepsAlignment() {
let input = """
struct Example: View {
var body: some View {
Text("Example")
.frame(maxWidth: 500, alignment: .leading)
#if !os(tvOS)
.font(.system(size: 14, design: .monospaced))
#endif
.padding(10)
}
}
"""
let options = FormatOptions(ifdefIndent: .preserve)
testFormatting(for: input, rule: .indent, options: options)
}
func testIfDefPreserveWithinIndentedChain() {
let input = """
struct ContentView: View {
var body: some View {
VStack {
Text("Hello World")
}
.foregroundStyle(Color.white)
#if os(iOS)
.frame(maxWidth: .infinity, maxHeight: .infinity)
#endif
}
}
"""
let options = FormatOptions(ifdefIndent: .preserve)
testFormatting(for: input, rule: .indent, options: options)
}
func testIfDefPreserveWithinNestedChainBlock() {
let input = """
struct ContentView: View {
var body: some View {
VStack {
Text("Hello World")
}
.foregroundStyle(Color.white)
#if os(iOS)
.background {
Color.black
}
#endif
}
}
"""
let options = FormatOptions(ifdefIndent: .preserve)
testFormatting(for: input, rule: .indent, options: options)
}
func testIfDefPreserveWithinNestedChainBlock2() {
let input = """
struct ContentView: View {
var body: some View {
VStack {
Text("Hello World")
}
.foregroundStyle(Color.white)
#if os(iOS)
.background {
Color.black
.overlay {
Color.white
}
}
#endif
}
}
"""
let options = FormatOptions(ifdefIndent: .preserve)
testFormatting(for: input, rule: .indent, options: options)
}
func testIfDefPreserveWithinNestedChainBlock3() {
let input = """
struct ContentView: View {
var body: some View {
VStack {
Text("Hello World")
}
.foregroundStyle(Color.white)
#if os(iOS)
.background {
Color.black
.overlay {
Color.white
.mask {
Circle()
}
}
}
#endif
}
}
"""
let options = FormatOptions(ifdefIndent: .preserve)
testFormatting(for: input, rule: .indent, options: options)
}
func testIfDefPreserveWithinNestedChainBlock4() {
let input = """
struct ContentView: View {
var body: some View {
VStack {
Text("Hello World")
}
.foregroundStyle(Color.white)
#if os(iOS)
.background {
Color.black
.overlay {
Color.white
.mask {
Circle()
.overlay {
Rectangle()
}
}
}
}
#endif
}
}
"""
let options = FormatOptions(ifdefIndent: .preserve)
testFormatting(for: input, rule: .indent, options: options)
}
func testIfDefPreserveWithCommentBeforeModifier() {
let input = """
struct ContentView: View {
var body: some View {
Text("Hello")
.frame(maxWidth: 200)
#if os(iOS)
// comment about padding
.padding(4)
#endif
}
}
"""
let options = FormatOptions(ifdefIndent: .preserve)
testFormatting(for: input, rule: .indent, options: options)
}
func testIfDefPreserveWithMultiplePlatformBranches() {
let input = """
import SwiftUI
import SwiftUIIntrospect
import Testing
@MainActor
@Suite
struct NavigationViewWithColumnsStyleTests {
#if canImport(UIKit) && (os(iOS) || os(visionOS))
typealias PlatformNavigationViewWithColumnsStyle = UISplitViewController
#elseif canImport(UIKit) && os(tvOS)
typealias PlatformNavigationViewWithColumnsStyle = UINavigationController
#elseif canImport(AppKit)
typealias PlatformNavigationViewWithColumnsStyle = NSSplitView
#endif
@Test func introspect() async throws {
try await introspection(of: PlatformNavigationViewWithColumnsStyle.self) { spy in
NavigationView {
ZStack {
Color.red
Text("Something")
}
}
.navigationViewStyle(DoubleColumnNavigationViewStyle())
#if os(iOS) || os(visionOS)
.introspect(.navigationView(style: .columns), on: .iOS(.v13, .v14, .v15, .v16, .v17, .v18, .v26), .visionOS(.v1, .v2, .v26), customize: spy)
#elseif os(tvOS)
.introspect(.navigationView(style: .columns), on: .tvOS(.v13, .v14, .v15, .v16, .v17, .v18, .v26), customize: spy)
#elseif os(macOS)
.introspect(.navigationView(style: .columns), on: .macOS(.v10_15, .v11, .v12, .v13, .v14, .v15, .v26), customize: spy)
#endif
}
}
@Test func introspectAsAncestor() async throws {
try await introspection(of: PlatformNavigationViewWithColumnsStyle.self) { spy in
NavigationView {
ZStack {
Color.red
Text("Something")
#if os(iOS) || os(visionOS)
.introspect(.navigationView(style: .columns), on: .iOS(.v13, .v14, .v15, .v16, .v17, .v18, .v26), .visionOS(.v1, .v2, .v26), scope: .ancestor, customize: spy)
#elseif os(tvOS)
.introspect(.navigationView(style: .columns), on: .tvOS(.v13, .v14, .v15, .v16, .v17, .v18, .v26), scope: .ancestor, customize: spy)
#elseif os(macOS)
.introspect(.navigationView(style: .columns), on: .macOS(.v10_15, .v11, .v12, .v13, .v14, .v15, .v26), scope: .ancestor, customize: spy)
#endif
}
}
.navigationViewStyle(DoubleColumnNavigationViewStyle())
#if os(iOS)
// NB: this is necessary for ancestor introspection to work, because initially on iPad the "Customized" text isn't shown as it's hidden in the sidebar. This is why ancestor introspection is discouraged for most situations and it's opt-in.
.introspect(.navigationView(style: .columns), on: .iOS(.v13, .v14, .v15, .v16, .v17, .v18, .v26)) {
$0.preferredDisplayMode = .oneOverSecondary
}
#endif
}
}
}
"""
let options = FormatOptions(ifdefIndent: .preserve)
testFormatting(for: input, rule: .indent, options: options)
}
func testIfDefPreserveMultipleModifiersInChain() {
let input = """
struct ContentView: View {
var body: some View {
Text("Example")
.frame(maxWidth: 200)
#if os(iOS)
.padding(4)
.background {
Color.red
.overlay {
Text("Inner")
}
}
.cornerRadius(8)
#endif
}
}
"""
let options = FormatOptions(ifdefIndent: .preserve)
testFormatting(for: input, rule: .indent, options: options)
}
func testIfDefPreserveWithElseIfBranches() {
let input = """
struct ContentView: View {
var body: some View {
Text("Example")
.frame(maxWidth: 200)
#if os(iOS)
.padding(4)
.background {
Color.red
}
#elseif os(macOS)
.padding(10)
.background {
Color.blue
.overlay {
Circle()
}
}
#else
.foregroundColor(.gray)
.shadow(radius: 2)
#endif
}
}
"""
let options = FormatOptions(ifdefIndent: .preserve)
testFormatting(for: input, rule: .indent, options: options)
}
// indent #if/#else/#elseif/#endif (mode: outdent)
func testIfEndifOutdenting() {