Skip to content

Commit

Permalink
Support for embedding resources in an executable
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
neonichu committed Feb 8, 2023
1 parent a227ff0 commit f231059
Show file tree
Hide file tree
Showing 13 changed files with 101 additions and 8 deletions.
10 changes: 10 additions & 0 deletions Fixtures/Resources/EmbedInCodeSimple/Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// swift-tools-version: 999.0

import PackageDescription

let package = Package(
name: "EmbedInCodeSimple",
targets: [
.executableTarget(name: "EmbedInCodeSimple", resources: [.embedInCode("best.txt")]),
]
)
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
hello world
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import Foundation

print("\(String(decoding: Data(PackageResources.best_txt), as: UTF8.self))")
41 changes: 40 additions & 1 deletion Sources/Build/BuildDescription/SwiftTargetBuildDescription.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
11 changes: 8 additions & 3 deletions Sources/Build/LLBuildManifestBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions Sources/PackageDescription/Resource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
2 changes: 2 additions & 0 deletions Sources/PackageLoading/ManifestJSONParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
}
Expand Down
9 changes: 9 additions & 0 deletions Sources/PackageLoading/PackageBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)"
}
}
}
Expand Down Expand Up @@ -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,
Expand Down
15 changes: 11 additions & 4 deletions Sources/PackageLoading/TargetSourcesBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}
}
Expand Down
1 change: 1 addition & 0 deletions Sources/PackageModel/Manifest/TargetDescription.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 2 additions & 0 deletions Sources/PackageModel/ManifestSourceGeneration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
1 change: 1 addition & 0 deletions Sources/PackageModel/Resource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,5 +44,6 @@ public struct Resource: Codable, Equatable {
public enum Rule: Codable, Equatable {
case process(localization: String?)
case copy
case embedInCode
}
}
7 changes: 7 additions & 0 deletions Tests/FunctionalTests/ResourcesTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}
}

0 comments on commit f231059

Please sign in to comment.