Skip to content

Commit 0018e14

Browse files
committed
Add infrastructure to define indexed single-file workspaces inside the tests
The new approach has a few advantages over the olde TIBS-based approach: 1. The source file being tested is defined within the test case itself and not in a separate file, which makes it easier to understand the test case since the auxiliaury file doesn’t need to be opened. Finding it inside `Sources/SKTestSupport/INPUTS` is already hard for people that are not familiar with the codebase. 2. The build setup is significantly simpler since it doesn’t rely on `ninja`. It is thus easier to understand what is run during the test. 3. We can use the emoji location markers to refer to test locations, like we do for files that are opened using `TestSourceKitLSPClient.openDocument`. This commit only migrates call hierarchy testing to the new design. If we like it, I’ll migrate the other test workspaces as well.
1 parent 0ef499a commit 0018e14

File tree

8 files changed

+390
-90
lines changed

8 files changed

+390
-90
lines changed

Package.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@ let package = Package(
245245
dependencies: [
246246
"CSKTestSupport",
247247
"LSPTestSupport",
248+
"SKCore",
248249
"SourceKitLSP",
249250
.product(name: "ISDBTestSupport", package: "indexstore-db"),
250251
.product(name: "SwiftToolsSupport-auto", package: "swift-tools-support-core"),

Sources/LanguageServerProtocol/Requests/PollIndexRequest.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,6 @@
1818
public struct PollIndexRequest: RequestType {
1919
public static var method: String = "workspace/_pollIndex"
2020
public typealias Response = VoidResponse
21+
22+
public init() {}
2123
}

Sources/SKTestSupport/INPUTS/CallHierarchy/a.swift

Lines changed: 0 additions & 22 deletions
This file was deleted.

Sources/SKTestSupport/INPUTS/CallHierarchy/project.json

Lines changed: 0 additions & 1 deletion
This file was deleted.
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2014 - 2022 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 LanguageServerProtocol
14+
import SKCore
15+
import Foundation
16+
import ISDBTibs
17+
import SourceKitLSP
18+
import TSCBasic
19+
20+
public struct IndexedSingleSwiftFileWorkspace {
21+
enum Error: Swift.Error {
22+
case swiftcNotFound
23+
}
24+
25+
public let testClient: TestSourceKitLSPClient
26+
public let fileURI: DocumentURI
27+
public let positions: DocumentPositions
28+
29+
public init(
30+
_ markedText: String,
31+
testName: String = #function
32+
) async throws{
33+
34+
// Build file paths
35+
36+
let testBaseName = testName.prefix(while: \.isLetter)
37+
let testWorkspaceDirectory = FileManager.default.temporaryDirectory
38+
.realpath
39+
.appendingPathComponent(String(testBaseName))
40+
.appendingPathComponent(UUID().uuidString)
41+
42+
try FileManager.default.createDirectory(at: testWorkspaceDirectory, withIntermediateDirectories: true)
43+
44+
let testFileURL = testWorkspaceDirectory.appendingPathComponent("test.swift")
45+
let indexURL = testWorkspaceDirectory.appendingPathComponent("index")
46+
let indexDBURL = testWorkspaceDirectory.appendingPathComponent("index-db")
47+
let toolchain = ToolchainRegistry.shared.default!
48+
guard let swiftc = toolchain.swiftc?.asURL else {
49+
throw Error.swiftcNotFound
50+
}
51+
52+
// Create workspace with source file and compile_commands.json
53+
54+
try extractMarkers(markedText).textWithoutMarkers.write(to: testFileURL, atomically: false, encoding: .utf8)
55+
56+
let compilerArguments: [String] = [
57+
testFileURL.path,
58+
"-sdk", TibsBuilder.defaultSDKPath!,
59+
"-index-store-path", indexURL.path,
60+
"-index-ignore-system-modules",
61+
"-o", testWorkspaceDirectory.appendingPathComponent("test.out").path
62+
]
63+
64+
let compilationDatabase = JSONCompilationDatabase(
65+
[
66+
JSONCompilationDatabase.Command(
67+
directory: testWorkspaceDirectory.path,
68+
filename: testFileURL.path,
69+
commandLine: [swiftc.path] + compilerArguments
70+
)
71+
]
72+
)
73+
let encoder = JSONEncoder()
74+
encoder.outputFormatting = .prettyPrinted
75+
try encoder.encode(compilationDatabase).write(to: testWorkspaceDirectory.appendingPathComponent("compile_commands.json"))
76+
77+
// Run swiftc to build the index store
78+
try ProcessRunner(executableURL: swiftc, arguments: compilerArguments)
79+
.run(verbose: false)
80+
81+
// Create the test client
82+
var options = SourceKitServer.Options.testDefault
83+
options.indexOptions = IndexOptions(
84+
indexStorePath: try AbsolutePath(validating: indexURL.path),
85+
indexDatabasePath: try AbsolutePath(validating: indexDBURL.path)
86+
)
87+
self.testClient = try await TestSourceKitLSPClient(
88+
serverOptions: options,
89+
workspaceFolders: [
90+
WorkspaceFolder(uri: DocumentURI(testWorkspaceDirectory))
91+
],
92+
cleanUp: {
93+
try? FileManager.default.removeItem(at: testWorkspaceDirectory)
94+
}
95+
)
96+
97+
// Wait for the indexstore-db to finish indexing
98+
_ = try await testClient.send(PollIndexRequest())
99+
100+
// Open the document
101+
self.fileURI = DocumentURI(testFileURL)
102+
self.positions = testClient.openDocument(markedText, uri: fileURI)
103+
}
104+
}
105+
106+
107+
108+
fileprivate extension URL {
109+
/// Assuming this is a file URL, resolves all symlinks in the path.
110+
///
111+
/// - Note: We need this because `URL.resolvingSymlinksInPath()` not only resolves symlinks but also standardizes the
112+
/// path by stripping away `private` prefixes. Since sourcekitd is not performing this standardization, using
113+
/// `resolvingSymlinksInPath` can lead to slightly mismatched URLs between the sourcekit-lsp response and the test
114+
/// assertion.
115+
var realpath: URL {
116+
return self.path.withCString { path in
117+
guard let realpath = Darwin.realpath(path, nil) else {
118+
return self
119+
}
120+
let result = URL(fileURLWithPath: String(cString: realpath))
121+
free(realpath)
122+
return result
123+
}
124+
}
125+
}
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift.org open source project
4+
//
5+
// Copyright (c) 2014 - 2023 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+
15+
/// Keeps track of subprocesses spawned by this script and forwards SIGINT
16+
/// signals to them.
17+
class SigIntListener {
18+
/// The subprocesses spawned by this script that are currently running.
19+
static var runningSubprocesses: Set<Process> = []
20+
21+
/// Whether a `SIGINT` signal has been received by this script.
22+
static var hasReceivedSigInt: Bool = false
23+
24+
/// Registers a `SIGINT` signal handler that forwards `SIGINT` to all
25+
/// subprocesses that are registered in `runningSubprocesses`
26+
static func registerSigIntSubprocessTerminationHandler() {
27+
#if canImport(Darwin) || canImport(Glibc)
28+
signal(SIGINT) { _ in
29+
SigIntListener.hasReceivedSigInt = true
30+
for process in SigIntListener.runningSubprocesses {
31+
process.interrupt()
32+
}
33+
}
34+
#endif
35+
}
36+
}
37+
38+
/// Provides convenience APIs for launching and gathering output from a subprocess
39+
public class ProcessRunner {
40+
private static let serialQueue = DispatchQueue(label: "\(ProcessRunner.self)")
41+
42+
private let process: Process
43+
44+
public init(
45+
executableURL: URL,
46+
arguments: [String],
47+
additionalEnvironment: [String: String] = [:]
48+
) {
49+
process = Process()
50+
process.executableURL = executableURL
51+
process.arguments = arguments
52+
process.environment = additionalEnvironment.merging(ProcessInfo.processInfo.environment) { (additional, _) in additional }
53+
}
54+
55+
@discardableResult
56+
public func run(
57+
captureStdout: Bool = true,
58+
captureStderr: Bool = true,
59+
verbose: Bool
60+
) throws -> ProcessResult {
61+
if verbose {
62+
print(process.command)
63+
}
64+
65+
let group = DispatchGroup()
66+
67+
var stdoutData = Data()
68+
if captureStdout {
69+
let outPipe = Pipe()
70+
process.standardOutput = outPipe
71+
addHandler(pipe: outPipe, group: group) { stdoutData.append($0) }
72+
}
73+
74+
var stderrData = Data()
75+
if captureStderr {
76+
let errPipe = Pipe()
77+
process.standardError = errPipe
78+
addHandler(pipe: errPipe, group: group) { stderrData.append($0) }
79+
}
80+
81+
try process.run()
82+
SigIntListener.runningSubprocesses.insert(process)
83+
process.waitUntilExit()
84+
SigIntListener.runningSubprocesses.remove(process)
85+
if captureStdout || captureStderr {
86+
// Make sure we've received all stdout/stderr
87+
group.wait()
88+
}
89+
90+
guard let stdoutString = String(data: stdoutData, encoding: .utf8) else {
91+
throw FailedToDecodeUTF8Error(data: stdoutData)
92+
}
93+
guard let stderrString = String(data: stderrData, encoding: .utf8) else {
94+
throw FailedToDecodeUTF8Error(data: stderrData)
95+
}
96+
97+
guard process.terminationStatus == 0 else {
98+
throw NonZeroExitCodeError(
99+
process: process,
100+
stdout: stdoutString,
101+
stderr: stderrString,
102+
exitCode: Int(process.terminationStatus)
103+
)
104+
}
105+
106+
return ProcessResult(
107+
stdout: stdoutString,
108+
stderr: stderrString
109+
)
110+
}
111+
112+
private func addHandler(
113+
pipe: Pipe,
114+
group: DispatchGroup,
115+
addData: @escaping (Data) -> Void
116+
) {
117+
group.enter()
118+
pipe.fileHandleForReading.readabilityHandler = { fileHandle in
119+
// Apparently using availableData can cause various issues
120+
let newData = fileHandle.readData(ofLength: Int.max)
121+
if newData.count == 0 {
122+
pipe.fileHandleForReading.readabilityHandler = nil;
123+
group.leave()
124+
} else {
125+
addData(newData)
126+
}
127+
}
128+
}
129+
}
130+
131+
/// The exit code and output (if redirected) from a subprocess that has
132+
/// terminated
133+
public struct ProcessResult {
134+
public let stdout: String
135+
public let stderr: String
136+
}
137+
138+
/// Error thrown if a process terminates with a non-zero exit code.
139+
struct NonZeroExitCodeError: Error, CustomStringConvertible {
140+
let process: Process
141+
let stdout: String
142+
let stderr: String
143+
let exitCode: Int
144+
145+
var description: String {
146+
var result = """
147+
Command failed with non-zero exit code \(exitCode):
148+
Command: \(process.command)
149+
"""
150+
if !stdout.isEmpty {
151+
result += """
152+
Standard output:
153+
\(stdout)
154+
"""
155+
}
156+
if !stderr.isEmpty {
157+
result += """
158+
Standard error:
159+
\(stderr)
160+
"""
161+
}
162+
return result
163+
}
164+
}
165+
166+
/// Error thrown if `stdout` or `stderr` could not be decoded as UTF-8.
167+
struct FailedToDecodeUTF8Error: Error {
168+
let data: Data
169+
}
170+
171+
extension Process {
172+
var command: String {
173+
var message = ""
174+
175+
for (key, value) in environment?.sorted(by: { $0.key < $1.key }) ?? [] {
176+
message += "\(key)='\(value)' "
177+
}
178+
179+
if let executableURL = executableURL {
180+
message += executableURL.path
181+
}
182+
183+
if let arguments = arguments {
184+
message += " \(arguments.joined(separator: " "))"
185+
}
186+
187+
return message
188+
}
189+
}

0 commit comments

Comments
 (0)