diff --git a/Rules.md b/Rules.md index 7ba864f3..f04673ca 100644 --- a/Rules.md +++ b/Rules.md @@ -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) diff --git a/Sources/Inference.swift b/Sources/Inference.swift index 04a89b11..4e64aba7 100644 --- a/Sources/Inference.swift +++ b/Sources/Inference.swift @@ -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 } diff --git a/Sources/Options.swift b/Sources/Options.swift index b8a764db..b0cfec5f 100644 --- a/Sources/Options.swift +++ b/Sources/Options.swift @@ -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: diff --git a/Sources/Rules/Indent.swift b/Sources/Rules/Indent.swift index d2d2b955..a4678828 100644 --- a/Sources/Rules/Indent.swift +++ b/Sources/Rules/Indent.swift @@ -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", diff --git a/Tests/InferenceTests.swift b/Tests/InferenceTests.swift index c20a2514..b00c801a 100644 --- a/Tests/InferenceTests.swift +++ b/Tests/InferenceTests.swift @@ -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 diff --git a/Tests/Rules/IndentTests.swift b/Tests/Rules/IndentTests.swift index 726ae789..30ab47c0 100644 --- a/Tests/Rules/IndentTests.swift +++ b/Tests/Rules/IndentTests.swift @@ -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() {