From f2310598d83c48c71b0dbdbf5438446b62913ded Mon Sep 17 00:00:00 2001 From: Boris Buegling Date: Fri, 20 Jan 2023 12:26:59 +0100 Subject: [PATCH] Support for embedding resources in an executable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Basic support for a new `.embed` resource rule which will allow embedding the contents of the resource into the executable code by generating a byte array, e.g. ``` struct PackageResources { static let best_txt: [UInt8] = [104,101,108,108,111,32,119,111,114,108,100,10] } ``` Note that the current naïve implementaton will not work well for larger resources as it is pretty memory inefficient. --- .../Resources/EmbedInCodeSimple/Package.swift | 10 +++++ .../Sources/EmbedInCodeSimple/best.txt | 1 + .../Sources/EmbedInCodeSimple/main.swift | 3 ++ .../SwiftTargetBuildDescription.swift | 41 ++++++++++++++++++- Sources/Build/LLBuildManifestBuilder.swift | 11 +++-- Sources/PackageDescription/Resource.swift | 6 +++ .../PackageLoading/ManifestJSONParser.swift | 2 + Sources/PackageLoading/PackageBuilder.swift | 9 ++++ .../PackageLoading/TargetSourcesBuilder.swift | 15 +++++-- .../Manifest/TargetDescription.swift | 1 + .../ManifestSourceGeneration.swift | 2 + Sources/PackageModel/Resource.swift | 1 + Tests/FunctionalTests/ResourcesTests.swift | 7 ++++ 13 files changed, 101 insertions(+), 8 deletions(-) create mode 100644 Fixtures/Resources/EmbedInCodeSimple/Package.swift create mode 100644 Fixtures/Resources/EmbedInCodeSimple/Sources/EmbedInCodeSimple/best.txt create mode 100644 Fixtures/Resources/EmbedInCodeSimple/Sources/EmbedInCodeSimple/main.swift diff --git a/Fixtures/Resources/EmbedInCodeSimple/Package.swift b/Fixtures/Resources/EmbedInCodeSimple/Package.swift new file mode 100644 index 00000000000..71e7ad4ae92 --- /dev/null +++ b/Fixtures/Resources/EmbedInCodeSimple/Package.swift @@ -0,0 +1,10 @@ +// swift-tools-version: 999.0 + +import PackageDescription + +let package = Package( + name: "EmbedInCodeSimple", + targets: [ + .executableTarget(name: "EmbedInCodeSimple", resources: [.embedInCode("best.txt")]), + ] +) diff --git a/Fixtures/Resources/EmbedInCodeSimple/Sources/EmbedInCodeSimple/best.txt b/Fixtures/Resources/EmbedInCodeSimple/Sources/EmbedInCodeSimple/best.txt new file mode 100644 index 00000000000..3b18e512dba --- /dev/null +++ b/Fixtures/Resources/EmbedInCodeSimple/Sources/EmbedInCodeSimple/best.txt @@ -0,0 +1 @@ +hello world diff --git a/Fixtures/Resources/EmbedInCodeSimple/Sources/EmbedInCodeSimple/main.swift b/Fixtures/Resources/EmbedInCodeSimple/Sources/EmbedInCodeSimple/main.swift new file mode 100644 index 00000000000..3210d0bd99d --- /dev/null +++ b/Fixtures/Resources/EmbedInCodeSimple/Sources/EmbedInCodeSimple/main.swift @@ -0,0 +1,3 @@ +import Foundation + +print("\(String(decoding: Data(PackageResources.best_txt), as: UTF8.self))") diff --git a/Sources/Build/BuildDescription/SwiftTargetBuildDescription.swift b/Sources/Build/BuildDescription/SwiftTargetBuildDescription.swift index e9bca7e1010..4711d2b2801 100644 --- a/Sources/Build/BuildDescription/SwiftTargetBuildDescription.swift +++ b/Sources/Build/BuildDescription/SwiftTargetBuildDescription.swift @@ -50,13 +50,21 @@ public final class SwiftTargetBuildDescription { /// Path to the bundle generated for this module (if any). var bundlePath: AbsolutePath? { - if let bundleName = target.underlyingTarget.potentialBundleName, !resources.isEmpty { + if let bundleName = target.underlyingTarget.potentialBundleName, needsResourceBundle { return self.buildParameters.bundlePath(named: bundleName) } else { return .none } } + private var needsResourceBundle: Bool { + return resources.filter { $0.rule != .embedInCode }.isEmpty == false + } + + private var needsResourceEmbedding: Bool { + return resources.filter { $0.rule == .embedInCode }.isEmpty == false + } + /// The list of all source files in the target, including the derived ones. public var sources: [AbsolutePath] { self.target.sources.paths + self.derivedSources.paths + self.pluginDerivedSources.paths @@ -284,6 +292,37 @@ public final class SwiftTargetBuildDescription { self.resourceBundleInfoPlistPath = infoPlistPath } } + + try self.generateResourceEmbeddingCode() + } + + // FIXME: This will not work well for large files, as we will store the entire contents, plus its byte array representation in memory and also `writeIfChanged()` will read the entire generated file again. + private func generateResourceEmbeddingCode() throws { + guard needsResourceEmbedding else { return } + + let stream = BufferedOutputByteStream() + stream <<< """ + struct PackageResources { + + """ + + try resources.forEach { + guard $0.rule == .embedInCode else { return } + + let variableName = $0.path.basename.spm_mangledToC99ExtendedIdentifier() + let fileContent = try Data(contentsOf: URL(fileURLWithPath: $0.path.pathString)).map { String($0) }.joined(separator: ",") + + stream <<< "static let \(variableName): [UInt8] = [\(fileContent)]\n" + } + + stream <<< """ + } + """ + + let subpath = RelativePath("embedded_resources.swift") + self.derivedSources.relativePaths.append(subpath) + let path = self.derivedSources.root.appending(subpath) + try self.fileSystem.writeIfChanged(path: path, bytes: stream.bytes) } /// Generate the resource bundle accessor, if appropriate. diff --git a/Sources/Build/LLBuildManifestBuilder.swift b/Sources/Build/LLBuildManifestBuilder.swift index ff269c6d519..473ebbe9b5b 100644 --- a/Sources/Build/LLBuildManifestBuilder.swift +++ b/Sources/Build/LLBuildManifestBuilder.swift @@ -174,9 +174,14 @@ extension LLBuildManifestBuilder { // Create a copy command for each resource file. for resource in target.resources { - let destination = bundlePath.appending(resource.destination) - let (_, output) = addCopyCommand(from: resource.path, to: destination) - outputs.append(output) + switch resource.rule { + case .copy, .process: + let destination = bundlePath.appending(resource.destination) + let (_, output) = addCopyCommand(from: resource.path, to: destination) + outputs.append(output) + case .embedInCode: + break + } } // Create a copy command for the Info.plist if a resource with the same name doesn't exist yet. diff --git a/Sources/PackageDescription/Resource.swift b/Sources/PackageDescription/Resource.swift index c1012ab70bb..d1c2aba4dda 100644 --- a/Sources/PackageDescription/Resource.swift +++ b/Sources/PackageDescription/Resource.swift @@ -94,4 +94,10 @@ public struct Resource: Encodable { public static func copy(_ path: String) -> Resource { return Resource(rule: "copy", path: path, localization: nil) } + + /// Applies the embed rule to a resource at the given path. + @available(_PackageDescription, introduced: 999.0) + public static func embedInCode(_ path: String) -> Resource { + return Resource(rule: "embedInCode", path: path, localization: nil) + } } diff --git a/Sources/PackageLoading/ManifestJSONParser.swift b/Sources/PackageLoading/ManifestJSONParser.swift index 84f1d26660a..97c90e0da93 100644 --- a/Sources/PackageLoading/ManifestJSONParser.swift +++ b/Sources/PackageLoading/ManifestJSONParser.swift @@ -362,6 +362,8 @@ enum ManifestJSONParser { return .init(rule: .process(localization: localization), path: path.pathString) case "copy": return .init(rule: .copy, path: path.pathString) + case "embedInCode": + return .init(rule: .embedInCode, path: path.pathString) default: throw InternalError("invalid resource rule \(rule)") } diff --git a/Sources/PackageLoading/PackageBuilder.swift b/Sources/PackageLoading/PackageBuilder.swift index bc65936e79e..1636e1baf67 100644 --- a/Sources/PackageLoading/PackageBuilder.swift +++ b/Sources/PackageLoading/PackageBuilder.swift @@ -76,6 +76,9 @@ public enum ModuleError: Swift.Error { /// A plugin target didn't declare a capability. case pluginCapabilityNotDeclared(target: String) + + /// A C target has declared an embedded resource + case embedInCodeNotSupported(target: String) } extension ModuleError: CustomStringConvertible { @@ -124,6 +127,8 @@ extension ModuleError: CustomStringConvertible { return "manifest property 'defaultLocalization' not set; it is required in the presence of localized resources" case .pluginCapabilityNotDeclared(let target): return "plugin target '\(target)' doesn't have a 'capability' property" + case .embedInCodeNotSupported(let target): + return "embedding resources in code not supported for C-family language target \(target)" } } } @@ -886,6 +891,10 @@ public final class PackageBuilder { moduleMapType = .none } + if resources.contains(where: { $0.rule == .embedInCode }) { + throw ModuleError.embedInCodeNotSupported(target: potentialModule.name) + } + return try ClangTarget( name: potentialModule.name, potentialBundleName: potentialBundleName, diff --git a/Sources/PackageLoading/TargetSourcesBuilder.swift b/Sources/PackageLoading/TargetSourcesBuilder.swift index ed08c3b5bc8..239a03dc926 100644 --- a/Sources/PackageLoading/TargetSourcesBuilder.swift +++ b/Sources/PackageLoading/TargetSourcesBuilder.swift @@ -298,8 +298,10 @@ public struct TargetSourcesBuilder { } return Resource(rule: .process(localization: implicitLocalization ?? explicitLocalization), path: path) - case .copy: + case .copyResource: return Resource(rule: .copy, path: path) + case .embedResourceInCode: + return Resource(rule: .embedInCode, path: path) } } @@ -504,7 +506,7 @@ public struct TargetSourcesBuilder { } else { observabilityScope.emit(warning: "Only Swift is supported for generated plugin source files at this time: \(absPath)") } - case .copy, .processResource: + case .copyResource, .processResource, .embedResourceInCode: if let resource = Self.resource(for: absPath, with: rule, defaultLocalization: defaultLocalization, targetName: targetName, targetPath: targetPath, observabilityScope: observabilityScope) { resources.append(resource) } else { @@ -537,8 +539,11 @@ public struct FileRuleDescription { /// This defaults to copy if there's no specialized behavior. case processResource(localization: TargetDescription.Resource.Localization?) + /// The embed rule. + case embedResourceInCode + /// The copy rule. - case copy + case copyResource /// The modulemap rule. case modulemap @@ -709,7 +714,9 @@ extension FileRuleDescription.Rule { case .process(let localization): self = .processResource(localization: localization) case .copy: - self = .copy + self = .copyResource + case .embedInCode: + self = .embedResourceInCode } } } diff --git a/Sources/PackageModel/Manifest/TargetDescription.swift b/Sources/PackageModel/Manifest/TargetDescription.swift index a2f3251f714..443246930a0 100644 --- a/Sources/PackageModel/Manifest/TargetDescription.swift +++ b/Sources/PackageModel/Manifest/TargetDescription.swift @@ -42,6 +42,7 @@ public struct TargetDescription: Equatable, Encodable { public enum Rule: Encodable, Equatable { case process(localization: Localization?) case copy + case embedInCode } public enum Localization: String, Encodable { diff --git a/Sources/PackageModel/ManifestSourceGeneration.swift b/Sources/PackageModel/ManifestSourceGeneration.swift index c906d8e6969..a587462696e 100644 --- a/Sources/PackageModel/ManifestSourceGeneration.swift +++ b/Sources/PackageModel/ManifestSourceGeneration.swift @@ -422,6 +422,8 @@ fileprivate extension SourceCodeFragment { self.init(enum: "process", subnodes: params) case .copy: self.init(enum: "copy", subnodes: params) + case .embedInCode: + self.init(enum: "embedInCode", subnodes: params) } } diff --git a/Sources/PackageModel/Resource.swift b/Sources/PackageModel/Resource.swift index 4e9d1e8b3fc..4fb05f3925a 100644 --- a/Sources/PackageModel/Resource.swift +++ b/Sources/PackageModel/Resource.swift @@ -44,5 +44,6 @@ public struct Resource: Codable, Equatable { public enum Rule: Codable, Equatable { case process(localization: String?) case copy + case embedInCode } } diff --git a/Tests/FunctionalTests/ResourcesTests.swift b/Tests/FunctionalTests/ResourcesTests.swift index 9a0c9234014..65cabf3bb4a 100644 --- a/Tests/FunctionalTests/ResourcesTests.swift +++ b/Tests/FunctionalTests/ResourcesTests.swift @@ -124,4 +124,11 @@ class ResourcesTests: XCTestCase { XCTAssertSwiftTest(fixturePath, extraArgs: ["--filter", "ClangResourceTests"]) } } + + func testResourcesEmbeddedInCode() throws { + try fixture(name: "Resources/EmbedInCodeSimple") { fixturePath in + let result = try executeSwiftRun(fixturePath, "EmbedInCodeSimple") + XCTAssertEqual(result.stdout, "hello world\n\n") + } + } }