Skip to content

Commit df1e396

Browse files
authored
Add an ExtensionEvaluator for evaluating extensions in a package graph (#3286)
* Allow the feature flag to enable package extensions to be controlled by input parameters to the package graph in addition to the SWIFTPM_ENABLE_EXTENSION_TARGETS environment variable, so that unit tests can selectively enable it for synthetic-package tests. * 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. * Change a force unwrap to an internal error, and remove some unnecessary formatting of the JSON encoding.
1 parent 64d9c3f commit df1e396

File tree

5 files changed

+511
-1
lines changed

5 files changed

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

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)