Skip to content

Commit ab655b1

Browse files
committed
Add a first version of an ExtensionEvaluator that allows all use of extensions in targets to be evaluated and that returns the commands to run and other information. Some TODOs and FIXMEs in the code for things left to be filled in, such as product dependencies and separate extension usage arrays.
1 parent a631f1a commit ab655b1

File tree

5 files changed

+509
-1
lines changed

5 files changed

+509
-1
lines changed

Package.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,9 @@ let package = Package(
251251
.testTarget(
252252
name: "PackageDescription4Tests",
253253
dependencies: ["PackageDescription"]),
254+
.testTarget(
255+
name: "SPMBuildCoreTests",
256+
dependencies: ["SPMBuildCore", "SPMTestSupport"]),
254257
.testTarget(
255258
name: "PackageLoadingTests",
256259
dependencies: ["PackageLoading", "SPMTestSupport"],

Sources/SPMBuildCore/CMakeLists.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# This source file is part of the Swift.org open source project
22
#
3-
# Copyright (c) 2014 - 2019 Apple Inc. and the Swift project authors
3+
# Copyright (c) 2014 - 2021 Apple Inc. and the Swift project authors
44
# Licensed under Apache License v2.0 with Runtime Library Exception
55
#
66
# See http://swift.org/LICENSE.txt for license information
@@ -12,6 +12,7 @@ add_library(SPMBuildCore
1212
BuildSystemCommand.swift
1313
BuildSystemDelegate.swift
1414
BuiltTestProduct.swift
15+
ExtensionEvaluator.swift
1516
Sanitizers.swift
1617
Toolchain.swift)
1718
# NOTE(compnerd) workaround for CMake not setting up include flags yet
Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
1+
/*
2+
This source file is part of the Swift.org open source project
3+
4+
Copyright (c) 2021 Apple Inc. and the Swift project authors
5+
Licensed under Apache License v2.0 with Runtime Library Exception
6+
7+
See http://swift.org/LICENSE.txt for license information
8+
See http://swift.org/CONTRIBUTORS.txt for Swift project authors
9+
*/
10+
11+
import Foundation
12+
import PackageModel
13+
import PackageGraph
14+
import TSCBasic
15+
16+
17+
extension PackageGraph {
18+
19+
/// Traverses the graph of reachable targets in a package graph and evaluates extensions as needed. Each extension is passed an input context that provides information about the target to which it is being applied (and some information from its dependency closure), and can generate an output in the form of commands that will later be run during the build. This function returns a mapping of resolved targets to the results of running each of the extensions against the target in turn. This include an ordered list of generated commands to run for each extension capability. This function may cache anything it wants to under the `cacheDir` directory. The `execsDir` directory is where executables for any dependencies of targets will be made available. Any warnings and errors related to running the extension will be emitted to `diagnostics`, and this function will throw an error if evaluation of any extension fails. Note that warnings emitted by the the extension itself will be returned in the ExtensionEvaluationResult structures and not added directly to the diagnostics engine.
20+
public func evaluateExtensions(
21+
buildEnvironment: BuildEnvironment,
22+
execsDir: AbsolutePath,
23+
outputDir: AbsolutePath,
24+
extensionRunner: ExtensionRunner,
25+
diagnostics: DiagnosticsEngine,
26+
fileSystem: FileSystem
27+
) throws -> [ResolvedTarget: [ExtensionEvaluationResult]] {
28+
// TODO: Convert this to be asynchronous, taking a completion closure. This may require changes to package graph APIs.
29+
var evalResultsByTarget: [ResolvedTarget: [ExtensionEvaluationResult]] = [:]
30+
31+
for target in self.reachableTargets {
32+
// Infer extensions from the declared dependencies, and collect them as well as any regular dependnencies.
33+
// TODO: We'll want to separate out extension usages from dependencies, but for now we get them from dependencies.
34+
var extensionTargets: [ExtensionTarget] = []
35+
var dependencyTargets: [Target] = []
36+
for dependency in target.dependencies(satisfying: buildEnvironment) {
37+
switch dependency {
38+
case .target(let target, _):
39+
if let extensionTarget = target.underlyingTarget as? ExtensionTarget {
40+
extensionTargets.append(extensionTarget)
41+
}
42+
else {
43+
dependencyTargets.append(target.underlyingTarget)
44+
}
45+
case .product(_, _):
46+
// TODO: Support extension product dependencies.
47+
break
48+
}
49+
}
50+
51+
// Leave quickly in the common case of not using any extensions.
52+
if extensionTargets.isEmpty {
53+
continue
54+
}
55+
56+
// If this target does use any extensions, create the input context to pass to them.
57+
// FIXME: We'll want to decide on what directories to provide to the extenion
58+
let package = self.packages.first{ $0.targets.contains(target) }!
59+
let extOutputsDir = outputDir.appending(components: "extensions", package.name, target.c99name, "outputs")
60+
let extCachesDir = outputDir.appending(components: "extensions", package.name, target.c99name, "caches")
61+
let extensionInput = ExtensionEvaluationInput(
62+
targetName: target.name,
63+
moduleName: target.c99name,
64+
targetDir: target.sources.root.pathString,
65+
packageDir: package.path.pathString,
66+
sourceFiles: target.sources.paths.map{ $0.pathString },
67+
dependencies: dependencyTargets.map {
68+
.init(targetName: $0.name, moduleName: $0.c99name, targetDir: $0.sources.root.pathString)
69+
},
70+
// FIXME: We'll want to adjust these output locations
71+
outputDir: extOutputsDir.pathString,
72+
cacheDir: extCachesDir.pathString,
73+
execsDir: execsDir.pathString,
74+
options: [:]
75+
)
76+
77+
// Evaluate each extension in turn, creating a list of results (one for each extension used by the target).
78+
var evalResults: [ExtensionEvaluationResult] = []
79+
for extTarget in extensionTargets {
80+
// Create the output and cache directories, if needed.
81+
do {
82+
try fileSystem.createDirectory(extOutputsDir, recursive: true)
83+
}
84+
catch {
85+
throw ExtensionEvaluationError.outputDirectoryCouldNotBeCreated(path: extOutputsDir, underlyingError: error)
86+
}
87+
do {
88+
try fileSystem.createDirectory(extCachesDir, recursive: true)
89+
}
90+
catch {
91+
throw ExtensionEvaluationError.outputDirectoryCouldNotBeCreated(path: extCachesDir, underlyingError: error)
92+
}
93+
94+
// Run the extension in the context of the target, and generate commands from the output.
95+
// TODO: This should be asynchronous.
96+
let (extensionOutput, emittedText) = try runExtension(
97+
sources: extTarget.sources,
98+
input: extensionInput,
99+
extensionRunner: extensionRunner,
100+
diagnostics: diagnostics,
101+
fileSystem: fileSystem
102+
)
103+
104+
// Generate emittable Diagnostics from the extension output.
105+
let diagnostics: [Diagnostic] = extensionOutput.diagnostics.map { diag in
106+
// FIXME: The implementation here is unfortunate; better Diagnostic APIs would make it cleaner.
107+
let location = diag.file.map {
108+
ExtensionEvaluationResult.FileLineLocation(file: $0, line: diag.line)
109+
}
110+
let message: Diagnostic.Message
111+
switch diag.severity {
112+
case .error: message = .error(diag.message)
113+
case .warning: message = .warning(diag.message)
114+
case .remark: message = .remark(diag.message)
115+
}
116+
if let location = location {
117+
return Diagnostic(message: message, location: location)
118+
}
119+
else {
120+
return Diagnostic(message: message)
121+
}
122+
}
123+
124+
// Generate commands from the extension output.
125+
let commands: [ExtensionEvaluationResult.Command] = extensionOutput.commands.map { cmd in
126+
let displayName = cmd.displayName
127+
let execPath = AbsolutePath(cmd.executable)
128+
let arguments = cmd.arguments
129+
let workingDir = cmd.workingDirectory.map{ AbsolutePath($0) }
130+
let environment = cmd.environment
131+
switch extTarget.capability {
132+
case .prebuild:
133+
let derivedSourceDirPaths = cmd.derivedSourcePaths.map{ AbsolutePath($0) }
134+
return .prebuildCommand(displayName: displayName, execPath: execPath, arguments: arguments, workingDir: workingDir, environment: environment, derivedSourceDirPaths: derivedSourceDirPaths)
135+
case .buildTool:
136+
let inputPaths = cmd.inputPaths.map{ AbsolutePath($0) }
137+
let outputPaths = cmd.outputPaths.map{ AbsolutePath($0) }
138+
let derivedSourcePaths = cmd.derivedSourcePaths.map{ AbsolutePath($0) }
139+
return .buildToolCommand(displayName: displayName, execPath: execPath, arguments: arguments, workingDir: workingDir, environment: environment, inputPaths: inputPaths, outputPaths: outputPaths, derivedSourcePaths: derivedSourcePaths)
140+
case .postbuild:
141+
return .postbuildCommand(displayName: displayName, execPath: execPath, arguments: arguments, workingDir: workingDir, environment: environment)
142+
}
143+
}
144+
145+
// Create an evaluation result from the usage of the extension by the target.
146+
let textOutput = String(decoding: emittedText, as: UTF8.self)
147+
evalResults.append(ExtensionEvaluationResult(extension: extTarget, commands: commands, diagnostics: diagnostics, textOutput: textOutput))
148+
}
149+
150+
// Associate the list of results with the target. The list will have one entry for each extension used by the target.
151+
evalResultsByTarget[target] = evalResults
152+
}
153+
return evalResultsByTarget
154+
}
155+
156+
157+
/// Private helper function that serializes an ExtensionEvaluationInput as input JSON, calls the extension runner to invoke the extension, and finally deserializes the output JSON it emits to a ExtensionEvaluationOutput. Adds any errors or warnings to `diagnostics`, and throws an error if there was a failure.
158+
/// FIXME: This should be asynchronous, taking a queue and a completion closure.
159+
fileprivate func runExtension(sources: Sources, input: ExtensionEvaluationInput, extensionRunner: ExtensionRunner, diagnostics: DiagnosticsEngine, fileSystem: FileSystem) throws -> (output: ExtensionEvaluationOutput, stdoutText: Data) {
160+
// Serialize the ExtensionEvaluationInput to JSON.
161+
let encoder = JSONEncoder()
162+
encoder.outputFormatting = [.prettyPrinted, .withoutEscapingSlashes]
163+
let inputJSON = try encoder.encode(input)
164+
165+
// Call the extension runner.
166+
let (outputJSON, stdoutText) = try extensionRunner.runExtension(sources: sources, inputJSON: inputJSON, diagnostics: diagnostics, fileSystem: fileSystem)
167+
168+
// Deserialize the JSON to an ExtensionEvaluationOutput.
169+
let output: ExtensionEvaluationOutput
170+
do {
171+
let decoder = JSONDecoder()
172+
output = try decoder.decode(ExtensionEvaluationOutput.self, from: outputJSON)
173+
}
174+
catch {
175+
throw ExtensionEvaluationError.decodingExtensionOutputFailed(json: outputJSON, underlyingError: error)
176+
}
177+
return (output: output, stdoutText: stdoutText)
178+
}
179+
}
180+
181+
182+
/// Represents the result of evaluating an extension against a particular resolved-target. This includes generated
183+
/// commands as well as any diagnostics or output emitted by the extension.
184+
public struct ExtensionEvaluationResult {
185+
/// The extension that produced the results.
186+
public let `extension`: ExtensionTarget
187+
188+
/// The commands generated by the extension (in order).
189+
public let commands: [Command]
190+
191+
/// A command provided by an extension. Extensions are evaluated after package graph resolution (and subsequently,
192+
/// if conditions change). Each extension specifies capabilities the capability it provides, which determines what
193+
/// kinds of commands it generates (when they run during the build, and the specific semantics surrounding them).
194+
public enum Command {
195+
196+
/// A command to run before the start of every build. Besides the obvious parameters, it can provide a list of
197+
/// directories whose contents should be considered as inputs to the set of source files to which build rules
198+
/// should be applied.
199+
case prebuildCommand(
200+
displayName: String,
201+
execPath: AbsolutePath,
202+
arguments: [String],
203+
workingDir: AbsolutePath?,
204+
environment: [String: String]?,
205+
derivedSourceDirPaths: [AbsolutePath]
206+
)
207+
208+
/// A command to be incorporated into the build graph, so that it runs when any of the outputs are missing or
209+
/// the inputs have changed from the last time when it ran. This is the preferred kind of command to generate
210+
/// when the input and output paths are known. In addition to inputs and outputs, the command can specify one
211+
/// or more files that should be considered as inputs to the set of source files to which build rules should
212+
/// be applied.
213+
case buildToolCommand(
214+
displayName: String,
215+
execPath: AbsolutePath,
216+
arguments: [String],
217+
workingDir: AbsolutePath?,
218+
environment: [String: String]?,
219+
inputPaths: [AbsolutePath],
220+
outputPaths: [AbsolutePath],
221+
derivedSourcePaths: [AbsolutePath]
222+
)
223+
224+
/// A command to run after the end of every build.
225+
case postbuildCommand(
226+
displayName: String,
227+
execPath: AbsolutePath,
228+
arguments: [String],
229+
workingDir: AbsolutePath?,
230+
environment: [String: String]?
231+
)
232+
}
233+
234+
// Any diagnostics emitted by the extension.
235+
public let diagnostics: [Diagnostic]
236+
237+
// A location representing a file name or path and an optional line number.
238+
// FIXME: This should be part of the Diagnostics APIs.
239+
struct FileLineLocation: DiagnosticLocation {
240+
let file: String
241+
let line: Int?
242+
var description: String {
243+
"\(file)\(line.map{":\($0)"} ?? "")"
244+
}
245+
}
246+
247+
// Any textual output emitted by the extension.
248+
public let textOutput: String
249+
}
250+
251+
252+
/// An error in extension evaluation.
253+
public enum ExtensionEvaluationError: Swift.Error {
254+
case outputDirectoryCouldNotBeCreated(path: AbsolutePath, underlyingError: Error)
255+
case runningExtensionFailed(underlyingError: Error)
256+
case decodingExtensionOutputFailed(json: Data, underlyingError: Error)
257+
}
258+
259+
260+
/// Implements the mechanics of running an extension script (implemented as a set of Swift source files) as a process.
261+
public protocol ExtensionRunner {
262+
263+
/// Implements the mechanics of running an extension script implemented as a set of Swift source files, for use
264+
/// by the package graph when it is evaluating package extensions.
265+
///
266+
/// The `sources` refer to the Swift source files and are accessible in the provided `fileSystem`. The input is
267+
/// a serialized ExtensionEvaluationContext, and the output should be a serialized ExtensionEvaluationOutput as
268+
/// well as any free-form output produced by the script (for debugging purposes).
269+
///
270+
/// Any errors or warnings related to the running of the extension will be added to `diagnostics`. Any errors
271+
/// or warnings emitted by the extension itself will be part of the returned output.
272+
///
273+
/// Every concrete implementation should cache any intermediates as necessary for fast evaluation.
274+
func runExtension(
275+
sources: Sources,
276+
inputJSON: Data,
277+
diagnostics: DiagnosticsEngine,
278+
fileSystem: FileSystem
279+
) throws -> (outputJSON: Data, stdoutText: Data)
280+
}
281+
282+
283+
/// Serializable context that's passed as input to the evaluation of the extension.
284+
struct ExtensionEvaluationInput: Codable {
285+
var targetName: String
286+
var moduleName: String
287+
var targetDir: String
288+
var packageDir: String
289+
var sourceFiles: [String]
290+
var dependencies: [DependencyTarget]
291+
public struct DependencyTarget: Codable {
292+
var targetName: String
293+
var moduleName: String
294+
var targetDir: String
295+
}
296+
var outputDir: String
297+
var cacheDir: String
298+
var execsDir: String
299+
var options: [String: String]
300+
}
301+
302+
303+
/// Deserializable result that's received as output from the evaluation of the extension.
304+
struct ExtensionEvaluationOutput: Codable {
305+
let version: Int
306+
let diagnostics: [Diagnostic]
307+
struct Diagnostic: Codable {
308+
enum Severity: String, Codable {
309+
case error, warning, remark
310+
}
311+
let severity: Severity
312+
let message: String
313+
let file: String?
314+
let line: Int?
315+
}
316+
317+
var commands: [GeneratedCommand]
318+
struct GeneratedCommand: Codable {
319+
let displayName: String
320+
let executable: String
321+
let arguments: [String]
322+
let workingDirectory: String?
323+
let environment: [String: String]?
324+
let inputPaths: [String]
325+
let outputPaths: [String]
326+
let derivedSourcePaths: [String]
327+
}
328+
}

Sources/SPMTestSupport/PackageGraphTester.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,10 @@ public final class ResolvedTargetResult {
136136
}
137137
body(ResolvedTargetDependencyResult(dependency))
138138
}
139+
140+
public func check(type: Target.Kind, file: StaticString = #file, line: UInt = #line) {
141+
XCTAssertEqual(type, target.type, file: file, line: line)
142+
}
139143
}
140144

141145
public final class ResolvedTargetDependencyResult {

0 commit comments

Comments
 (0)