Skip to content

Commit 9415d03

Browse files
committed
Allow reduction of non-crashers using the diagnose subcommand and inline import statements
1 parent 194cde2 commit 9415d03

File tree

5 files changed

+236
-43
lines changed

5 files changed

+236
-43
lines changed

Sources/Diagnose/CommandLineArgumentsReducer.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ class CommandLineArgumentReducer {
9090
reducedRequestInfo.compilerArgs.removeSubrange(argumentsToRemove)
9191

9292
let result = try await sourcekitdExecutor.run(request: reducedRequestInfo.request(for: temporarySourceFile))
93-
if result == .crashed {
93+
if case .reproducesIssue = result {
9494
logSuccessfulReduction(reducedRequestInfo)
9595
return reducedRequestInfo
9696
} else {

Sources/Diagnose/DiagnoseCommand.swift

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,19 @@ public struct DiagnoseCommand: AsyncParsableCommand {
4343
)
4444
var sourcekitdOverride: String?
4545

46+
#if canImport(Darwin)
47+
// Creating an NSPredicate from a string is not supported in corelibs-foundation.
48+
@Option(
49+
help: """
50+
If the sourcekitd response matches this predicate, consider it as reproducing the issue.
51+
sourcekitd crashes are always considered as reproducers.
52+
53+
The predicate is an NSPredicate and `self` is the sourcekitd response.
54+
"""
55+
)
56+
var predicate: String?
57+
#endif
58+
4659
var sourcekitd: String? {
4760
get async throws {
4861
if let sourcekitdOverride {
@@ -79,7 +92,16 @@ public struct DiagnoseCommand: AsyncParsableCommand {
7992
print("-- Diagnosing \(name)")
8093
do {
8194
var requestInfo = requestInfo
82-
let executor = SourceKitRequestExecutor(sourcekitd: URL(fileURLWithPath: sourcekitd))
95+
var nspredicate: NSPredicate? = nil
96+
#if canImport(Darwin)
97+
if let predicate {
98+
nspredicate = NSPredicate(format: predicate)
99+
}
100+
#endif
101+
let executor = SourceKitRequestExecutor(
102+
sourcekitd: URL(fileURLWithPath: sourcekitd),
103+
reproducerPredicate: nspredicate
104+
)
83105
let fileReducer = FileReducer(sourcekitdExecutor: executor)
84106
requestInfo = try await fileReducer.run(initialRequestInfo: requestInfo)
85107

Sources/Diagnose/FileReducer.swift

Lines changed: 182 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -34,53 +34,100 @@ class FileReducer {
3434
var requestInfo = initialRequestInfo
3535
try await validateRequestInfoCrashes(requestInfo: requestInfo)
3636

37-
requestInfo = try await runReductionStep(requestInfo: requestInfo, reduce: removeComments) ?? requestInfo
37+
requestInfo = try await fatalErrorFunctionBodies(requestInfo)
38+
requestInfo = try await removeMembersAndCodeBlockItemsBodies(requestInfo)
39+
while let importInlined = try await inlineFirstImport(requestInfo) {
40+
requestInfo = importInlined
41+
requestInfo = try await fatalErrorFunctionBodies(requestInfo)
42+
// Generated interfaces are huge. Try removing multiple consecutive declarations at once
43+
// before going into fine-grained mode
44+
requestInfo = try await removeMembersAndCodeBlockItemsBodies(requestInfo, simultaneousRemove: 100)
45+
requestInfo = try await removeMembersAndCodeBlockItemsBodies(requestInfo, simultaneousRemove: 10)
46+
requestInfo = try await removeMembersAndCodeBlockItemsBodies(requestInfo)
47+
}
48+
49+
requestInfo = try await removeComments(requestInfo)
50+
51+
return requestInfo
52+
}
53+
54+
// MARK: - Reduction steps
55+
56+
private func validateRequestInfoCrashes(requestInfo: RequestInfo) async throws {
57+
let initialReproducer = try await runReductionStep(requestInfo: requestInfo) { tree in [] }
58+
if initialReproducer == nil {
59+
throw ReductionError("Initial request info did not crash")
60+
}
61+
}
3862

39-
requestInfo = try await runStatefulReductionStep(
63+
/// Replace function bodies by `fatalError()`
64+
private func fatalErrorFunctionBodies(_ requestInfo: RequestInfo) async throws -> RequestInfo {
65+
try await runStatefulReductionStep(
4066
requestInfo: requestInfo,
4167
reducer: ReplaceFunctionBodiesByFatalError()
4268
)
69+
}
4370

71+
/// Remove members and code block items.
72+
///
73+
/// When `simultaneousRemove` is set, this automatically removes `simultaneousRemove` number of adjacent items.
74+
/// This can significantly speed up the reduction of large files with many top-level items.
75+
private func removeMembersAndCodeBlockItemsBodies(
76+
_ requestInfo: RequestInfo,
77+
simultaneousRemove: Int = 1
78+
) async throws -> RequestInfo {
79+
var requestInfo = requestInfo
4480
// Run removal of members and code block items in a loop. Sometimes the removal of a code block item further down in the
4581
// file can remove the last reference to a member which can then be removed as well.
4682
while true {
4783
let reducedRequestInfo = try await runStatefulReductionStep(
4884
requestInfo: requestInfo,
49-
reducer: RemoveMembersAndCodeBlockItems()
85+
reducer: RemoveMembersAndCodeBlockItems(simultaneousRemove: simultaneousRemove)
5086
)
5187
if reducedRequestInfo.fileContents == requestInfo.fileContents {
5288
// No changes were made during reduction. We are done.
5389
break
5490
}
5591
requestInfo = reducedRequestInfo
5692
}
57-
5893
return requestInfo
5994
}
6095

61-
func logSuccessfulReduction(_ requestInfo: RequestInfo) {
62-
print("Reduced source file to \(requestInfo.fileContents.utf8.count) bytes")
96+
/// Remove comments from the source file.
97+
private func removeComments(_ requestInfo: RequestInfo) async throws -> RequestInfo {
98+
try await runReductionStep(requestInfo: requestInfo, reduce: removeComments(from:)) ?? requestInfo
6399
}
64100

65-
// MARK: - Running reduction steps
66-
67-
private func validateRequestInfoCrashes(requestInfo: RequestInfo) async throws {
68-
let initialReproducer = try await runReductionStep(requestInfo: requestInfo) { tree in [] }
69-
if initialReproducer == nil {
70-
throw ReductionError("Initial request info did not crash")
101+
/// Replace the first `import` declaration in the source file by the contents of the Swift interface.
102+
private func inlineFirstImport(_ requestInfo: RequestInfo) async throws -> RequestInfo? {
103+
try await runReductionStep(requestInfo: requestInfo) { tree in
104+
let edits = await Diagnose.inlineFirstImport(
105+
in: tree,
106+
executor: sourcekitdExecutor,
107+
compilerArgs: requestInfo.compilerArgs
108+
)
109+
return edits
71110
}
72111
}
73112

113+
// MARK: - Primitives to run reduction steps
114+
115+
func logSuccessfulReduction(_ requestInfo: RequestInfo) {
116+
print("Reduced source file to \(requestInfo.fileContents.utf8.count) bytes")
117+
}
118+
74119
/// Run a single reduction step.
75120
///
76121
/// If the request still crashes after applying the edits computed by `reduce`, return the reduced request info.
77122
/// Otherwise, return `nil`
78123
private func runReductionStep(
79124
requestInfo: RequestInfo,
80-
reduce: (_ tree: SourceFileSyntax) throws -> [SourceEdit]
125+
reduce: (_ tree: SourceFileSyntax) async throws -> [SourceEdit]?
81126
) async throws -> RequestInfo? {
82127
let tree = Parser.parse(source: requestInfo.fileContents)
83-
let edits = try reduce(tree)
128+
guard let edits = try await reduce(tree) else {
129+
return nil
130+
}
84131
let reducedSource = FixItApplier.apply(edits: edits, to: tree)
85132

86133
var adjustedOffset = requestInfo.offset
@@ -100,7 +147,7 @@ class FileReducer {
100147

101148
try reducedSource.write(to: temporarySourceFile, atomically: false, encoding: .utf8)
102149
let result = try await sourcekitdExecutor.run(request: reducedRequestInfo.request(for: temporarySourceFile))
103-
if result == .crashed {
150+
if case .reproducesIssue = result {
104151
logSuccessfulReduction(reducedRequestInfo)
105152
return reducedRequestInfo
106153
} else {
@@ -149,6 +196,8 @@ protocol StatefulReducer {
149196
func reduce(tree: SourceFileSyntax) -> [SourceEdit]
150197
}
151198

199+
// MARK: Replace function bodies
200+
152201
/// Tries replacing one function body by `fatalError()` at a time.
153202
class ReplaceFunctionBodiesByFatalError: StatefulReducer {
154203
/// The function bodies that should not be replaced by `fatalError()`.
@@ -205,15 +254,23 @@ class ReplaceFunctionBodiesByFatalError: StatefulReducer {
205254
}
206255
}
207256

257+
// MARK: Remove members and code block items
258+
208259
/// Tries removing `MemberBlockItemSyntax` and `CodeBlockItemSyntax` one at a time.
209260
class RemoveMembersAndCodeBlockItems: StatefulReducer {
210261
/// The code block items / members that shouldn't be removed.
211262
///
212263
/// See `ReplaceFunctionBodiesByFatalError.keepFunctionBodies`.
213264
var keepItems: [String] = []
214265

266+
let simultaneousRemove: Int
267+
268+
init(simultaneousRemove: Int) {
269+
self.simultaneousRemove = simultaneousRemove
270+
}
271+
215272
func reduce(tree: SourceFileSyntax) -> [SourceEdit] {
216-
let visitor = Visitor(keepMembers: keepItems)
273+
let visitor = Visitor(keepMembers: keepItems, maxEdits: simultaneousRemove)
217274
visitor.walk(tree)
218275
keepItems = visitor.keepItems
219276
return visitor.edits
@@ -222,21 +279,23 @@ class RemoveMembersAndCodeBlockItems: StatefulReducer {
222279
private class Visitor: SyntaxAnyVisitor {
223280
var keepItems: [String]
224281
var edits: [SourceEdit] = []
282+
let maxEdits: Int
225283

226-
init(keepMembers: [String]) {
284+
init(keepMembers: [String], maxEdits: Int) {
227285
self.keepItems = keepMembers
286+
self.maxEdits = maxEdits
228287
super.init(viewMode: .sourceAccurate)
229288
}
230289

231290
override func visitAny(_ node: Syntax) -> SyntaxVisitorContinueKind {
232-
if !edits.isEmpty {
291+
if edits.count >= maxEdits {
233292
return .skipChildren
234293
}
235294
return .visitChildren
236295
}
237296

238297
override func visit(_ node: MemberBlockItemSyntax) -> SyntaxVisitorContinueKind {
239-
if !edits.isEmpty {
298+
if edits.count >= maxEdits {
240299
return .skipChildren
241300
}
242301
if keepItems.contains(node.description.trimmingCharacters(in: .whitespacesAndNewlines)) {
@@ -249,7 +308,7 @@ class RemoveMembersAndCodeBlockItems: StatefulReducer {
249308
}
250309

251310
override func visit(_ node: CodeBlockItemSyntax) -> SyntaxVisitorContinueKind {
252-
if !edits.isEmpty {
311+
if edits.count >= maxEdits {
253312
return .skipChildren
254313
}
255314
if keepItems.contains(node.description.trimmingCharacters(in: .whitespacesAndNewlines)) {
@@ -304,3 +363,106 @@ fileprivate extension TriviaPiece {
304363
}
305364
}
306365
}
366+
367+
// MARK: Inline first include
368+
369+
class FirstImportFinder: SyntaxAnyVisitor {
370+
var firstImport: ImportDeclSyntax?
371+
372+
override func visitAny(_ node: Syntax) -> SyntaxVisitorContinueKind {
373+
if firstImport == nil {
374+
return .visitChildren
375+
} else {
376+
return .skipChildren
377+
}
378+
}
379+
380+
override func visit(_ node: ImportDeclSyntax) -> SyntaxVisitorContinueKind {
381+
if firstImport == nil {
382+
firstImport = node
383+
}
384+
return .skipChildren
385+
}
386+
387+
static func findFirstImport(in tree: some SyntaxProtocol) -> ImportDeclSyntax? {
388+
let visitor = FirstImportFinder(viewMode: .sourceAccurate)
389+
visitor.walk(tree)
390+
return visitor.firstImport
391+
}
392+
}
393+
394+
private func getSwiftInterface(_ moduleName: String, executor: SourceKitRequestExecutor, compilerArgs: [String])
395+
async throws -> String
396+
{
397+
// FIXME: Use the sourcekitd specified on the command line once rdar://121676425 is fixed
398+
let sourcekitdPath =
399+
"/Applications/Geode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/sourcekitdInProc.framework/sourcekitdInProc"
400+
let executor = SourceKitRequestExecutor(
401+
sourcekitd: URL(fileURLWithPath: sourcekitdPath),
402+
reproducerPredicate: nil
403+
)
404+
405+
// We use `RequestInfo` and its template to add the compiler arguments to the request.
406+
let requestTemplate = """
407+
{
408+
key.request: source.request.editor.open.interface,
409+
key.name: "fake",
410+
key.compilerargs: [
411+
$COMPILERARGS
412+
],
413+
key.modulename: "\(moduleName)"
414+
}
415+
"""
416+
let requestInfo = RequestInfo(
417+
requestTemplate: requestTemplate,
418+
offset: 0,
419+
compilerArgs: compilerArgs,
420+
fileContents: ""
421+
)
422+
let request = try requestInfo.request(for: URL(fileURLWithPath: "/"))
423+
424+
guard case .success(let result) = try await executor.run(request: request) else {
425+
throw ReductionError("Failed to get Swift Interface for \(moduleName)")
426+
}
427+
428+
// Extract the line containing the source text and parse that using JSON decoder.
429+
// We can't parse the entire response using `JSONEncoder` because the sourcekitd response isn't actually valid JSON
430+
// (it doesn't quote keys, for example). So, extract the string, which is actually correctly JSON encoded.
431+
let quotedSourceText = result.components(separatedBy: "\n").compactMap { (line) -> Substring? in
432+
let prefix = " key.sourcetext: "
433+
guard line.hasPrefix(prefix) else {
434+
return nil
435+
}
436+
var line: Substring = line[...]
437+
line = line.dropFirst(prefix.count)
438+
if line.hasSuffix(",") {
439+
line = line.dropLast()
440+
}
441+
return line
442+
}.only
443+
guard let quotedSourceText else {
444+
throw ReductionError("Failed to decode Swift interface response for \(moduleName)")
445+
}
446+
// Filter control characters. JSONDecoder really doensn't like them and they are likely not important if they occur eg. in a comment.
447+
let sanitizedData = Data(quotedSourceText.utf8.filter { $0 >= 32 })
448+
return try JSONDecoder().decode(String.self, from: sanitizedData)
449+
}
450+
451+
func inlineFirstImport(
452+
in tree: SourceFileSyntax,
453+
executor: SourceKitRequestExecutor,
454+
compilerArgs: [String]
455+
) async -> [SourceEdit]? {
456+
guard let firstImport = FirstImportFinder.findFirstImport(in: tree) else {
457+
return nil
458+
}
459+
guard let moduleName = firstImport.path.only?.name else {
460+
return nil
461+
}
462+
guard let interface = try? await getSwiftInterface(moduleName.text, executor: executor, compilerArgs: compilerArgs)
463+
else {
464+
return nil
465+
}
466+
let edit = SourceEdit(range: firstImport.position..<firstImport.endPosition, replacement: interface)
467+
return [edit]
468+
}

0 commit comments

Comments
 (0)