Skip to content

Commit 263ad3a

Browse files
authored
Allow libSwiftPM clients additional flexibility in the manifest source generation (#3782)
Allow clients of the code that can generate manifest source generation to provide a tools version header comment, a list of imports, and custom handling for product types (falling back on default behavior if they don't provide a custom source code fragment for the product). Also makes SourceCodeFragment available to clients and adds documentation code for it.
1 parent 95ff950 commit 263ad3a

File tree

2 files changed

+186
-45
lines changed

2 files changed

+186
-45
lines changed

Sources/PackageModel/ManifestSourceGeneration.swift

Lines changed: 89 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,6 @@
88
See http://swift.org/CONTRIBUTORS.txt for Swift project authors
99
*/
1010

11-
import TSCBasic
12-
import TSCUtility
1311
import Foundation
1412

1513

@@ -22,25 +20,51 @@ extension Manifest {
2220

2321
/// Generates and returns a string containing the contents of the manifest
2422
/// in canonical declarative form.
25-
public var generatedManifestFileContents: String {
26-
/// Only write out the major and minor (not patch) versions of the
27-
/// tools version, since the patch version doesn't change semantics.
28-
/// We leave out the spacer if the tools version doesn't support it.
23+
///
24+
/// - Parameters:
25+
/// - toolsVersionHeaderComment: Optional string to add to the `swift-tools-version` header (it will be ignored).
26+
/// - additionalImportModuleNames: Names of any modules to import besides PackageDescription (would commonly contain custom product type definitions).
27+
/// - customProductTypeSourceGenerator: Closure that will be called once for each custom product type in the manifest; it should return a SourceCodeFragment for the product type.
28+
///
29+
/// Returns: a string containing the full source code for the manifest.
30+
public func generateManifestFileContents(
31+
toolsVersionHeaderComment: String? = .none,
32+
additionalImportModuleNames: [String] = [],
33+
customProductTypeSourceGenerator: ManifestCustomProductTypeSourceGenerator? = .none
34+
) rethrows -> String {
35+
// Generate the source code fragment for the top level of the package
36+
// expression.
37+
let packageExprFragment = try SourceCodeFragment(from: self, customProductTypeSourceGenerator: customProductTypeSourceGenerator)
38+
39+
// Generate the source code from the module names and code fragment.
40+
// We only write out the major and minor (not patch) versions of the
41+
// tools version, since the patch version doesn't change semantics.
42+
// We leave out the spacer if the tools version doesn't support it.
43+
let toolsVersionSuffix = "\(toolsVersionHeaderComment.map{ "; \($0)" } ?? "")"
2944
return """
30-
\(toolsVersion.specification(roundedTo: .minor))
45+
\(toolsVersion.specification(roundedTo: .minor))\(toolsVersionSuffix)
3146
import PackageDescription
32-
33-
let package = \(SourceCodeFragment(from: self).generateSourceCode())
47+
\(additionalImportModuleNames.map{ "import \($0)\n" }.joined())
48+
let package = \(packageExprFragment.generateSourceCode())
3449
"""
3550
}
51+
52+
/// Generates and returns a string containing the contents of the manifest
53+
/// in canonical declarative form.
54+
public var generatedManifestFileContents: String {
55+
return self.generateManifestFileContents(customProductTypeSourceGenerator: nil)
56+
}
3657
}
3758

59+
/// Constructs and returns a SourceCodeFragment that represents the instantiation of a custom product type with the specified identifer and having the given serialized parameters (the contents of whom are a private matter between the serialized form in PackageDescription and the client). The generated source code should, if evaluated as a part of a package manifest, result in the same serialized parameters.
60+
public typealias ManifestCustomProductTypeSourceGenerator = (ProductDescription) throws -> SourceCodeFragment?
61+
3862

3963
/// Convenience initializers for package manifest structures.
4064
fileprivate extension SourceCodeFragment {
4165

4266
/// Instantiates a SourceCodeFragment to represent an entire manifest.
43-
init(from manifest: Manifest) {
67+
init(from manifest: Manifest, customProductTypeSourceGenerator: ManifestCustomProductTypeSourceGenerator?) rethrows {
4468
var params: [SourceCodeFragment] = []
4569

4670
params.append(SourceCodeFragment(key: "name", string: manifest.name))
@@ -64,7 +88,7 @@ fileprivate extension SourceCodeFragment {
6488
}
6589

6690
if !manifest.products.isEmpty {
67-
let nodes = manifest.products.map{ SourceCodeFragment(from: $0) }
91+
let nodes = try manifest.products.map{ try SourceCodeFragment(from: $0, customProductTypeSourceGenerator: customProductTypeSourceGenerator) }
6892
params.append(SourceCodeFragment(key: "products", subnodes: nodes))
6993
}
7094

@@ -157,27 +181,35 @@ fileprivate extension SourceCodeFragment {
157181
self.init(enum: "package", subnodes: params)
158182
}
159183

160-
/// Instantiates a SourceCodeFragment to represent a single product.
161-
init(from product: ProductDescription) {
162-
var params: [SourceCodeFragment] = []
163-
params.append(SourceCodeFragment(key: "name", string: product.name))
164-
if !product.targets.isEmpty {
165-
params.append(SourceCodeFragment(key: "targets", strings: product.targets))
166-
}
167-
switch product.type {
168-
case .library(let type):
169-
if type != .automatic {
170-
params.append(SourceCodeFragment(key: "type", enum: type.rawValue))
184+
/// Instantiates a SourceCodeFragment to represent a single product. If there's a custom product generator, it gets
185+
/// a chance to generate the source code fragments before checking the default types.
186+
init(from product: ProductDescription, customProductTypeSourceGenerator: ManifestCustomProductTypeSourceGenerator?) rethrows {
187+
// Use a custom source code fragment if we have a custom generator and it returns a value.
188+
if let customSubnode = try customProductTypeSourceGenerator?(product) {
189+
self = customSubnode
190+
}
191+
// Otherwise we use the default behavior.
192+
else {
193+
var params: [SourceCodeFragment] = []
194+
params.append(SourceCodeFragment(key: "name", string: product.name))
195+
if !product.targets.isEmpty {
196+
params.append(SourceCodeFragment(key: "targets", strings: product.targets))
197+
}
198+
switch product.type {
199+
case .library(let type):
200+
if type != .automatic {
201+
params.append(SourceCodeFragment(key: "type", enum: type.rawValue))
202+
}
203+
self.init(enum: "library", subnodes: params, multiline: true)
204+
case .executable:
205+
self.init(enum: "executable", subnodes: params, multiline: true)
206+
case .snippet:
207+
self.init(enum: "sample", subnodes: params, multiline: true)
208+
case .plugin:
209+
self.init(enum: "plugin", subnodes: params, multiline: true)
210+
case .test:
211+
self.init(enum: "test", subnodes: params, multiline: true)
171212
}
172-
self.init(enum: "library", subnodes: params, multiline: true)
173-
case .executable:
174-
self.init(enum: "executable", subnodes: params, multiline: true)
175-
case .snippet:
176-
self.init(enum: "sample", subnodes: params, multiline: true)
177-
case .plugin:
178-
self.init(enum: "plugin", subnodes: params, multiline: true)
179-
case .test:
180-
self.init(enum: "test", subnodes: params, multiline: true)
181213
}
182214
}
183215

@@ -423,14 +455,20 @@ fileprivate extension SourceCodeFragment {
423455

424456
/// Convenience initializers for key-value pairs of simple types. These make
425457
/// the logic above much simpler.
426-
fileprivate extension SourceCodeFragment {
458+
public extension SourceCodeFragment {
427459

428460
/// Initializes a SourceCodeFragment for a boolean in a generated manifest.
429461
init(key: String? = nil, boolean: Bool) {
430462
let prefix = key.map{ $0 + ": " } ?? ""
431463
self.init(prefix + (boolean ? "true" : "false"))
432464
}
433465

466+
/// Initializes a SourceCodeFragment for an integer in a generated manifest.
467+
init(key: String? = nil, integer: Int) {
468+
let prefix = key.map{ $0 + ": " } ?? ""
469+
self.init(prefix + "\(integer)")
470+
}
471+
434472
/// Initializes a SourceCodeFragment for a quoted string in a generated manifest.
435473
init(key: String? = nil, string: String) {
436474
let prefix = key.map{ $0 + ": " } ?? ""
@@ -478,24 +516,33 @@ fileprivate extension SourceCodeFragment {
478516
}
479517

480518

481-
482-
/// Helper type to emit source code. Represents one node of source code, as a
519+
/// Helper type to emit source code. Represents one node of source code, as an
483520
/// arbitrary string followed by an optional child list, optionally enclosed in
484-
/// a pair of delimiters. The code generation works by creating source code
485-
/// fragments and then rendering them as source code with proper formatting.
486-
fileprivate struct SourceCodeFragment {
487-
let literal: String
521+
/// a pair of delimiters.
522+
///
523+
/// The source code generation works by creating SourceCodeFragments, and then
524+
/// rendering them into string form with appropriate formatting.
525+
public struct SourceCodeFragment {
526+
/// A literal prefix to emit at the start of the source code fragment.
527+
var literal: String
528+
529+
/// The type of delimeters to use around the subfragments (if any).
488530
var delimiters: Delimiters
531+
532+
/// Whether or not to emit newlines before the subfragments (if any).
489533
var multiline: Bool
534+
535+
/// Any subfragments; no delimeters are emitted if none.
490536
var subnodes: [SourceCodeFragment]?
491-
492-
enum Delimiters {
537+
538+
/// Type of delimiters to emit around any subfragments.
539+
public enum Delimiters {
493540
case none
494541
case brackets
495542
case parentheses
496543
}
497544

498-
init(_ literal: String, delimiters: Delimiters = .none,
545+
public init(_ literal: String, delimiters: Delimiters = .none,
499546
multiline: Bool = true, subnodes: [SourceCodeFragment]? = nil) {
500547
self.literal = literal
501548
self.delimiters = delimiters
@@ -512,8 +559,8 @@ fileprivate struct SourceCodeFragment {
512559
case .parentheses: string.append("(")
513560
}
514561
if multiline { string.append("\n") }
562+
let subindent = indent + (multiline ? " " : "")
515563
for (idx, subnode) in subnodes.enumerated() {
516-
let subindent = indent + " "
517564
if multiline { string.append(subindent) }
518565
string.append(subnode.generateSourceCode(indent: subindent))
519566
if idx < subnodes.count-1 {

Tests/WorkspaceTests/ManifestSourceGenerationTests.swift

Lines changed: 97 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,15 @@ import XCTest
1818

1919
class ManifestSourceGenerationTests: XCTestCase {
2020

21-
/// Private function that writes the contents of a package manifest to a temporary package directory and then loads it, then serializes the loaded manifest back out again and loads it once again, after which it compares that no information was lost.
22-
private func testManifestWritingRoundTrip(manifestContents: String, toolsVersion: ToolsVersion, fs: FileSystem = localFileSystem) throws {
21+
/// Private function that writes the contents of a package manifest to a temporary package directory and then loads it, then serializes the loaded manifest back out again and loads it once again, after which it compares that no information was lost. Return the source of the newly generated manifest.
22+
@discardableResult
23+
private func testManifestWritingRoundTrip(
24+
manifestContents: String,
25+
toolsVersion: ToolsVersion,
26+
toolsVersionHeaderComment: String? = .none,
27+
additionalImportModuleNames: [String] = [],
28+
fs: FileSystem = localFileSystem
29+
) throws -> String {
2330
try withTemporaryDirectory { packageDir in
2431
// Write the original manifest file contents, and load it.
2532
try fs.writeFileContents(packageDir.appending(component: Manifest.filename), bytes: ByteString(encodingAsUTF8: manifestContents))
@@ -40,7 +47,9 @@ class ManifestSourceGenerationTests: XCTestCase {
4047
}
4148

4249
// Generate source code for the loaded manifest,
43-
let newContents = manifest.generatedManifestFileContents
50+
let newContents = try manifest.generateManifestFileContents(
51+
toolsVersionHeaderComment: toolsVersionHeaderComment,
52+
additionalImportModuleNames: additionalImportModuleNames)
4453

4554
// Check that the tools version was serialized properly.
4655
let versionSpacing = (toolsVersion >= .v5_4) ? " " : ""
@@ -76,6 +85,9 @@ class ManifestSourceGenerationTests: XCTestCase {
7685
XCTAssertEqual(newManifest.swiftLanguageVersions, manifest.swiftLanguageVersions, failureDetails)
7786
XCTAssertEqual(newManifest.cLanguageStandard, manifest.cLanguageStandard, failureDetails)
7887
XCTAssertEqual(newManifest.cxxLanguageStandard, manifest.cxxLanguageStandard, failureDetails)
88+
89+
// Return the generated manifest so that the caller can do further testing on it.
90+
return newContents
7991
}
8092
}
8193

@@ -322,4 +334,86 @@ class ManifestSourceGenerationTests: XCTestCase {
322334
"""
323335
try testManifestWritingRoundTrip(manifestContents: manifestContents, toolsVersion: .v5_5)
324336
}
337+
338+
func testCustomToolsVersionHeaderComment() throws {
339+
let manifestContents = """
340+
// swift-tools-version:5.5
341+
import PackageDescription
342+
343+
let package = Package(
344+
name: "Plugins",
345+
targets: [
346+
.plugin(
347+
name: "MyPlugin",
348+
capability: .buildTool(),
349+
dependencies: ["MyTool"]
350+
),
351+
.executableTarget(
352+
name: "MyTool"
353+
),
354+
]
355+
)
356+
"""
357+
let newContents = try testManifestWritingRoundTrip(manifestContents: manifestContents, toolsVersion: .v5_5, toolsVersionHeaderComment: "a comment")
358+
359+
XCTAssertTrue(newContents.hasPrefix("// swift-tools-version: 5.5; a comment\n"), "contents: \(newContents)")
360+
}
361+
362+
func testAdditionalModuleImports() throws {
363+
let manifestContents = """
364+
// swift-tools-version:5.5
365+
import PackageDescription
366+
import Foundation
367+
368+
let package = Package(
369+
name: "MyPkg",
370+
targets: [
371+
.executableTarget(
372+
name: "MyExec"
373+
),
374+
]
375+
)
376+
"""
377+
let newContents = try testManifestWritingRoundTrip(manifestContents: manifestContents, toolsVersion: .v5_5, additionalImportModuleNames: ["Foundation"])
378+
379+
XCTAssertTrue(newContents.contains("import Foundation\n"), "contents: \(newContents)")
380+
}
381+
382+
func testCustomProductSourceGeneration() throws {
383+
// Create a manifest containing a product for which we'd like to do custom source fragment generation.
384+
let manifest = Manifest(
385+
name: "MyLibrary",
386+
path: AbsolutePath("/tmp/MyLibrary/Package.swift"),
387+
packageKind: .root(AbsolutePath("/tmp/MyLibrary")),
388+
packageLocation: "/tmp/MyLibrary",
389+
platforms: [],
390+
toolsVersion: .v5_5,
391+
products: [
392+
.init(name: "Foo", type: .library(.static), targets: ["Bar"])
393+
]
394+
)
395+
396+
// Generate the manifest contents, using a custom source generator for the product type.
397+
let contents = manifest.generateManifestFileContents(customProductTypeSourceGenerator: { product in
398+
// This example handles library types in a custom way, for testing purposes.
399+
var params: [SourceCodeFragment] = []
400+
params.append(SourceCodeFragment(key: "name", string: product.name))
401+
if !product.targets.isEmpty {
402+
params.append(SourceCodeFragment(key: "targets", strings: product.targets))
403+
}
404+
// Handle .library specially (by not emitting as multiline), otherwise asking for default behavior.
405+
if case .library(let type) = product.type {
406+
if type != .automatic {
407+
params.append(SourceCodeFragment(key: "type", enum: type.rawValue))
408+
}
409+
return SourceCodeFragment(enum: "library", subnodes: params, multiline: false)
410+
}
411+
else {
412+
return nil
413+
}
414+
})
415+
416+
// Check that we generated what we expected.
417+
XCTAssertTrue(contents.contains(".library(name: \"Foo\", targets: [\"Bar\"], type: .static)"), "contents: \(contents)")
418+
}
325419
}

0 commit comments

Comments
 (0)