Skip to content

Commit 6640067

Browse files
DaveEwingahoppen
authored andcommitted
Support for formatting a selection (given as an array of ranges) <#297>.
The basic idea here is to insert `enableFormatting` and `disableFormatting` tokens into the print stream when we enter or leave the selection. When formatting is enabled, we print out the tokens as usual. When formatting is disabled, we turn off any output until the next `enableFormatting` token. When that token is hit, we write the original source text from the location of the last `disableFormatting` to the current location. Note that this means that all the APIs need the original source text to be passed in. A `Selection` is represented as an enum with an `.infinite` case, and a `.ranges` case to indicate either selecting the entire file, or an array of start/end utf-8 offsets. The offset pairs are given with `Range<AbsolutePosition>`, matching the (now common) usage in swift-syntax. For testing, allow marked text to use `⏩` and `⏪` to deliniate the start/end of a range of a selection. The command line now takes an `--offsets` option of comma-separated "start:end" pairs to set the selection for formatting.
1 parent 4f19acc commit 6640067

18 files changed

+722
-58
lines changed
+63
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2024 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See https://swift.org/LICENSE.txt for license information
9+
// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
import Foundation
14+
import SwiftSyntax
15+
16+
/// The selection as given on the command line - an array of offets and lengths
17+
public enum Selection {
18+
case infinite
19+
case ranges([Range<AbsolutePosition>])
20+
21+
/// Create a selection from an array of utf8 ranges. An empty array means an infinite selection.
22+
public init(offsetPairs: [Range<Int>]) {
23+
if offsetPairs.isEmpty {
24+
self = .infinite
25+
} else {
26+
let ranges = offsetPairs.map {
27+
AbsolutePosition(utf8Offset: $0.lowerBound) ..< AbsolutePosition(utf8Offset: $0.upperBound)
28+
}
29+
self = .ranges(ranges)
30+
}
31+
}
32+
33+
public func contains(_ position: AbsolutePosition) -> Bool {
34+
switch self {
35+
case .infinite:
36+
return true
37+
case .ranges(let ranges):
38+
return ranges.contains { $0.contains(position) }
39+
}
40+
}
41+
42+
public func overlapsOrTouches(_ range: Range<AbsolutePosition>) -> Bool {
43+
switch self {
44+
case .infinite:
45+
return true
46+
case .ranges(let ranges):
47+
return ranges.contains { $0.overlapsOrTouches(range) }
48+
}
49+
}
50+
}
51+
52+
53+
public extension Syntax {
54+
/// return true if the node is _completely_ inside any range in the selection
55+
func isInsideSelection(_ selection: Selection) -> Bool {
56+
switch selection {
57+
case .infinite:
58+
return true
59+
case .ranges(let ranges):
60+
return ranges.contains { return $0.lowerBound <= position && endPosition <= $0.upperBound }
61+
}
62+
}
63+
}

Sources/SwiftFormat/API/SwiftFormatter.swift

+16-15
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
//
33
// This source file is part of the Swift.org open source project
44
//
5-
// Copyright (c) 2014 - 2019 Apple Inc. and the Swift project authors
5+
// Copyright (c) 2014 - 2024 Apple Inc. and the Swift project authors
66
// Licensed under Apache License v2.0 with Runtime Library Exception
77
//
88
// See https://swift.org/LICENSE.txt for license information
@@ -21,6 +21,9 @@ public final class SwiftFormatter {
2121
/// The configuration settings that control the formatter's behavior.
2222
public let configuration: Configuration
2323

24+
/// the ranges of text to format
25+
public var selection: Selection = .infinite
26+
2427
/// An optional callback that will be notified with any findings encountered during formatting.
2528
public let findingConsumer: ((Finding) -> Void)?
2629

@@ -70,6 +73,7 @@ public final class SwiftFormatter {
7073
try format(
7174
source: String(contentsOf: url, encoding: .utf8),
7275
assumingFileURL: url,
76+
selection: .infinite,
7377
to: &outputStream,
7478
parsingDiagnosticHandler: parsingDiagnosticHandler)
7579
}
@@ -86,6 +90,7 @@ public final class SwiftFormatter {
8690
/// - url: A file URL denoting the filename/path that should be assumed for this syntax tree,
8791
/// which is associated with any diagnostics emitted during formatting. If this is nil, a
8892
/// dummy value will be used.
93+
/// - selection: The ranges to format
8994
/// - outputStream: A value conforming to `TextOutputStream` to which the formatted output will
9095
/// be written.
9196
/// - parsingDiagnosticHandler: An optional callback that will be notified if there are any
@@ -94,6 +99,7 @@ public final class SwiftFormatter {
9499
public func format<Output: TextOutputStream>(
95100
source: String,
96101
assumingFileURL url: URL?,
102+
selection: Selection,
97103
to outputStream: inout Output,
98104
parsingDiagnosticHandler: ((Diagnostic, SourceLocation) -> Void)? = nil
99105
) throws {
@@ -108,8 +114,8 @@ public final class SwiftFormatter {
108114
assumingFileURL: url,
109115
parsingDiagnosticHandler: parsingDiagnosticHandler)
110116
try format(
111-
syntax: sourceFile, operatorTable: .standardOperators, assumingFileURL: url, source: source,
112-
to: &outputStream)
117+
syntax: sourceFile, source: source, operatorTable: .standardOperators, assumingFileURL: url,
118+
selection: selection, to: &outputStream)
113119
}
114120

115121
/// Formats the given Swift syntax tree and writes the result to an output stream.
@@ -122,32 +128,26 @@ public final class SwiftFormatter {
122128
///
123129
/// - Parameters:
124130
/// - syntax: The Swift syntax tree to be converted to source code and formatted.
131+
/// - source: The original Swift source code used to build the syntax tree.
125132
/// - operatorTable: The table that defines the operators and their precedence relationships.
126133
/// This must be the same operator table that was used to fold the expressions in the `syntax`
127134
/// argument.
128135
/// - url: A file URL denoting the filename/path that should be assumed for this syntax tree,
129136
/// which is associated with any diagnostics emitted during formatting. If this is nil, a
130137
/// dummy value will be used.
138+
/// - selection: The ranges to format
131139
/// - outputStream: A value conforming to `TextOutputStream` to which the formatted output will
132140
/// be written.
133141
/// - Throws: If an unrecoverable error occurs when formatting the code.
134142
public func format<Output: TextOutputStream>(
135-
syntax: SourceFileSyntax, operatorTable: OperatorTable, assumingFileURL url: URL?,
136-
to outputStream: inout Output
137-
) throws {
138-
try format(
139-
syntax: syntax, operatorTable: operatorTable, assumingFileURL: url, source: nil,
140-
to: &outputStream)
141-
}
142-
143-
private func format<Output: TextOutputStream>(
144-
syntax: SourceFileSyntax, operatorTable: OperatorTable,
145-
assumingFileURL url: URL?, source: String?, to outputStream: inout Output
143+
syntax: SourceFileSyntax, source: String, operatorTable: OperatorTable,
144+
assumingFileURL url: URL?, selection: Selection, to outputStream: inout Output
146145
) throws {
147146
let assumedURL = url ?? URL(fileURLWithPath: "source")
148147
let context = Context(
149148
configuration: configuration, operatorTable: operatorTable, findingConsumer: findingConsumer,
150-
fileURL: assumedURL, sourceFileSyntax: syntax, source: source, ruleNameCache: ruleNameCache)
149+
fileURL: assumedURL, selection: selection, sourceFileSyntax: syntax, source: source,
150+
ruleNameCache: ruleNameCache)
151151
let pipeline = FormatPipeline(context: context)
152152
let transformedSyntax = pipeline.rewrite(Syntax(syntax))
153153

@@ -158,6 +158,7 @@ public final class SwiftFormatter {
158158

159159
let printer = PrettyPrinter(
160160
context: context,
161+
source: source,
161162
node: transformedSyntax,
162163
printTokenStream: debugOptions.contains(.dumpTokenStream),
163164
whitespaceOnly: false)

Sources/SwiftFormat/API/SwiftLinter.swift

+4-2
Original file line numberDiff line numberDiff line change
@@ -119,17 +119,18 @@ public final class SwiftLinter {
119119
/// - Throws: If an unrecoverable error occurs when formatting the code.
120120
public func lint(
121121
syntax: SourceFileSyntax,
122+
source: String,
122123
operatorTable: OperatorTable,
123124
assumingFileURL url: URL
124125
) throws {
125-
try lint(syntax: syntax, operatorTable: operatorTable, assumingFileURL: url, source: nil)
126+
try lint(syntax: syntax, operatorTable: operatorTable, assumingFileURL: url, source: source)
126127
}
127128

128129
private func lint(
129130
syntax: SourceFileSyntax,
130131
operatorTable: OperatorTable,
131132
assumingFileURL url: URL,
132-
source: String?
133+
source: String
133134
) throws {
134135
let context = Context(
135136
configuration: configuration, operatorTable: operatorTable, findingConsumer: findingConsumer,
@@ -145,6 +146,7 @@ public final class SwiftLinter {
145146
// pretty-printer.
146147
let printer = PrettyPrinter(
147148
context: context,
149+
source: source,
148150
node: Syntax(syntax),
149151
printTokenStream: debugOptions.contains(.dumpTokenStream),
150152
whitespaceOnly: true)

Sources/SwiftFormat/Core/Context.swift

+9-2
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
//
33
// This source file is part of the Swift.org open source project
44
//
5-
// Copyright (c) 2014 - 2019 Apple Inc. and the Swift project authors
5+
// Copyright (c) 2014 - 2024 Apple Inc. and the Swift project authors
66
// Licensed under Apache License v2.0 with Runtime Library Exception
77
//
88
// See https://swift.org/LICENSE.txt for license information
@@ -39,6 +39,9 @@ public final class Context {
3939
/// The configuration for this run of the pipeline, provided by a configuration JSON file.
4040
let configuration: Configuration
4141

42+
/// The optional ranges to process
43+
let selection: Selection
44+
4245
/// Defines the operators and their precedence relationships that were used during parsing.
4346
let operatorTable: OperatorTable
4447

@@ -66,6 +69,7 @@ public final class Context {
6669
operatorTable: OperatorTable,
6770
findingConsumer: ((Finding) -> Void)?,
6871
fileURL: URL,
72+
selection: Selection = .infinite,
6973
sourceFileSyntax: SourceFileSyntax,
7074
source: String? = nil,
7175
ruleNameCache: [ObjectIdentifier: String]
@@ -74,6 +78,7 @@ public final class Context {
7478
self.operatorTable = operatorTable
7579
self.findingEmitter = FindingEmitter(consumer: findingConsumer)
7680
self.fileURL = fileURL
81+
self.selection = selection
7782
self.importsXCTest = .notDetermined
7883
let tree = source.map { Parser.parse(source: $0) } ?? sourceFileSyntax
7984
self.sourceLocationConverter =
@@ -86,8 +91,10 @@ public final class Context {
8691
}
8792

8893
/// Given a rule's name and the node it is examining, determine if the rule is disabled at this
89-
/// location or not.
94+
/// location or not. Also makes sure the entire node is contained inside any selection.
9095
func isRuleEnabled<R: Rule>(_ rule: R.Type, node: Syntax) -> Bool {
96+
guard node.isInsideSelection(selection) else { return false }
97+
9198
let loc = node.startLocation(converter: self.sourceLocationConverter)
9299

93100
assert(

Sources/SwiftFormat/PrettyPrint/PrettyPrint.swift

+78-7
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
//===----------------------------------------------------------------------===//
1212

1313
import SwiftSyntax
14+
import Foundation
1415

1516
/// PrettyPrinter takes a Syntax node and outputs a well-formatted, re-indented reproduction of the
1617
/// code as a String.
@@ -66,6 +67,19 @@ public class PrettyPrinter {
6667
private var configuration: Configuration { return context.configuration }
6768
private let maxLineLength: Int
6869
private var tokens: [Token]
70+
private var source: String
71+
72+
/// keep track of where formatting was disabled in the original source
73+
///
74+
/// To format a selection, we insert `enableFormatting`/`disableFormatting` tokens into the
75+
/// stream when entering/exiting a selection range. Those tokens include utf8 offsets into the
76+
/// original source. When enabling formatting, we copy the text between `disabledPosition` and the
77+
/// current position to `outputBuffer`. From then on, we continue to format until the next
78+
/// `disableFormatting` token.
79+
private var disabledPosition: AbsolutePosition? = nil
80+
/// true if we're currently formatting
81+
private var writingIsEnabled: Bool { disabledPosition == nil }
82+
6983
private var outputBuffer: String = ""
7084

7185
/// The number of spaces remaining on the current line.
@@ -172,11 +186,14 @@ public class PrettyPrinter {
172186
/// - printTokenStream: Indicates whether debug information about the token stream should be
173187
/// printed to standard output.
174188
/// - whitespaceOnly: Whether only whitespace changes should be made.
175-
public init(context: Context, node: Syntax, printTokenStream: Bool, whitespaceOnly: Bool) {
189+
public init(context: Context, source: String, node: Syntax, printTokenStream: Bool, whitespaceOnly: Bool) {
176190
self.context = context
191+
self.source = source
177192
let configuration = context.configuration
178193
self.tokens = node.makeTokenStream(
179-
configuration: configuration, operatorTable: context.operatorTable)
194+
configuration: configuration,
195+
selection: context.selection,
196+
operatorTable: context.operatorTable)
180197
self.maxLineLength = configuration.lineLength
181198
self.spaceRemaining = self.maxLineLength
182199
self.printTokenStream = printTokenStream
@@ -216,7 +233,9 @@ public class PrettyPrinter {
216233
}
217234

218235
guard numberToPrint > 0 else { return }
219-
writeRaw(String(repeating: "\n", count: numberToPrint))
236+
if writingIsEnabled {
237+
writeRaw(String(repeating: "\n", count: numberToPrint))
238+
}
220239
lineNumber += numberToPrint
221240
isAtStartOfLine = true
222241
consecutiveNewlineCount += numberToPrint
@@ -238,13 +257,17 @@ public class PrettyPrinter {
238257
/// leading spaces that are required before the text itself.
239258
private func write(_ text: String) {
240259
if isAtStartOfLine {
241-
writeRaw(currentIndentation.indentation())
260+
if writingIsEnabled {
261+
writeRaw(currentIndentation.indentation())
262+
}
242263
spaceRemaining = maxLineLength - currentIndentation.length(in: configuration)
243264
isAtStartOfLine = false
244-
} else if pendingSpaces > 0 {
265+
} else if pendingSpaces > 0 && writingIsEnabled {
245266
writeRaw(String(repeating: " ", count: pendingSpaces))
246267
}
247-
writeRaw(text)
268+
if writingIsEnabled {
269+
writeRaw(text)
270+
}
248271
consecutiveNewlineCount = 0
249272
pendingSpaces = 0
250273
}
@@ -523,7 +546,9 @@ public class PrettyPrinter {
523546
}
524547

525548
case .verbatim(let verbatim):
526-
writeRaw(verbatim.print(indent: currentIndentation))
549+
if writingIsEnabled {
550+
writeRaw(verbatim.print(indent: currentIndentation))
551+
}
527552
consecutiveNewlineCount = 0
528553
pendingSpaces = 0
529554
lastBreak = false
@@ -569,6 +594,40 @@ public class PrettyPrinter {
569594
write(",")
570595
spaceRemaining -= 1
571596
}
597+
598+
case .enableFormatting(let enabledPosition):
599+
// if we're not disabled, we ignore the token
600+
if let disabledPosition {
601+
let start = source.utf8.index(source.utf8.startIndex, offsetBy: disabledPosition.utf8Offset)
602+
let end: String.Index
603+
if let enabledPosition {
604+
end = source.utf8.index(source.utf8.startIndex, offsetBy: enabledPosition.utf8Offset)
605+
} else {
606+
end = source.endIndex
607+
}
608+
var text = String(source[start..<end])
609+
// strip trailing whitespace so that the next formatting can add the right amount
610+
if let nonWhitespace = text.rangeOfCharacter(
611+
from: CharacterSet.whitespaces.inverted, options: .backwards) {
612+
text = String(text[..<nonWhitespace.upperBound])
613+
}
614+
615+
writeRaw(text)
616+
if text.hasSuffix("\n") {
617+
isAtStartOfLine = true
618+
consecutiveNewlineCount = 1
619+
} else {
620+
isAtStartOfLine = false
621+
consecutiveNewlineCount = 0
622+
}
623+
self.disabledPosition = nil
624+
}
625+
626+
case .disableFormatting(let newPosition):
627+
// a second disable is ignored
628+
if writingIsEnabled {
629+
disabledPosition = newPosition
630+
}
572631
}
573632
}
574633

@@ -673,6 +732,10 @@ public class PrettyPrinter {
673732
let length = isSingleElement ? 0 : 1
674733
total += length
675734
lengths.append(length)
735+
736+
case .enableFormatting, .disableFormatting:
737+
// no effect on length calculations
738+
lengths.append(0)
676739
}
677740
}
678741

@@ -775,6 +838,14 @@ public class PrettyPrinter {
775838
case .contextualBreakingEnd:
776839
printDebugIndent()
777840
print("[END BREAKING CONTEXT Idx: \(idx)]")
841+
842+
case .enableFormatting(let pos):
843+
printDebugIndent()
844+
print("[ENABLE FORMATTING utf8 offset: \(String(describing: pos))]")
845+
846+
case .disableFormatting(let pos):
847+
printDebugIndent()
848+
print("[DISABLE FORMATTING utf8 offset: \(pos)]")
778849
}
779850
}
780851

0 commit comments

Comments
 (0)