Scaffold v1.0.0
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
.build
|
||||||
|
public
|
||||||
36
Package.swift
Normal file
36
Package.swift
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
// swift-tools-version: 6.1
|
||||||
|
import PackageDescription
|
||||||
|
|
||||||
|
let package = Package(
|
||||||
|
name: "Embedder",
|
||||||
|
platforms: [
|
||||||
|
.macOS(.v13),
|
||||||
|
.iOS(.v16),
|
||||||
|
.tvOS(.v16),
|
||||||
|
.watchOS(.v9)
|
||||||
|
],
|
||||||
|
products: [
|
||||||
|
.plugin(
|
||||||
|
name: "Embedder",
|
||||||
|
targets: ["Embedder"]
|
||||||
|
)
|
||||||
|
],
|
||||||
|
targets: [
|
||||||
|
.plugin(
|
||||||
|
name: "Embedder",
|
||||||
|
capability: .buildTool(),
|
||||||
|
dependencies: [
|
||||||
|
.target(name: "EmbedderTool")
|
||||||
|
]
|
||||||
|
),
|
||||||
|
.executableTarget(
|
||||||
|
name: "EmbedderTool",
|
||||||
|
path: "Sources/EmbedderTool"
|
||||||
|
),
|
||||||
|
.testTarget(
|
||||||
|
name: "EmbedderToolTests",
|
||||||
|
dependencies: ["EmbedderTool"],
|
||||||
|
path: "Tests/EmbedderToolTests"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
69
Plugins/Embedder/EmbedderPlugin.swift
Normal file
69
Plugins/Embedder/EmbedderPlugin.swift
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import Foundation
|
||||||
|
import PackagePlugin
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct EmbedderPlugin: BuildToolPlugin {
|
||||||
|
func createBuildCommands(
|
||||||
|
context: PluginContext,
|
||||||
|
target: Target
|
||||||
|
) async throws -> [Command] {
|
||||||
|
guard let sourceModule = target as? SourceModuleTarget else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return try buildCommands(for: sourceModule, in: context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension EmbedderPlugin {
|
||||||
|
static let staticInlineDirectoryName = "Static Inline"
|
||||||
|
static let generatedFileName = "Embedded.swift"
|
||||||
|
static let toolName = "EmbedderTool"
|
||||||
|
|
||||||
|
func buildCommands(
|
||||||
|
for target: SourceModuleTarget,
|
||||||
|
in context: PluginContext
|
||||||
|
) throws -> [Command] {
|
||||||
|
guard let staticInlineDirectory = locateStaticInlineDirectory(in: target) else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
let inputFiles = try collectInputFiles(under: staticInlineDirectory)
|
||||||
|
guard !inputFiles.isEmpty else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
try makeBuildCommand(
|
||||||
|
sourceDirectory: staticInlineDirectory,
|
||||||
|
inputFiles: inputFiles,
|
||||||
|
context: context
|
||||||
|
)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
func locateStaticInlineDirectory(in target: SourceModuleTarget) -> URL? {
|
||||||
|
let candidate = target.directoryURL.appending(path: Self.staticInlineDirectoryName)
|
||||||
|
return FileSystem.isDirectory(at: candidate) ? candidate : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectInputFiles(under directory: URL) throws -> [URL] {
|
||||||
|
try FileSystem.regularFiles(under: directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeBuildCommand(
|
||||||
|
sourceDirectory: URL,
|
||||||
|
inputFiles: [URL],
|
||||||
|
context: PluginContext
|
||||||
|
) throws -> Command {
|
||||||
|
let tool = try context.tool(named: Self.toolName)
|
||||||
|
let outputFile = context.pluginWorkDirectoryURL.appending(path: Self.generatedFileName)
|
||||||
|
return .buildCommand(
|
||||||
|
displayName: "Embedding files from \(Self.staticInlineDirectoryName)",
|
||||||
|
executable: tool.url,
|
||||||
|
arguments: [
|
||||||
|
sourceDirectory.path(percentEncoded: false),
|
||||||
|
outputFile.path(percentEncoded: false)
|
||||||
|
],
|
||||||
|
inputFiles: inputFiles,
|
||||||
|
outputFiles: [outputFile]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
25
Plugins/Embedder/FileSystem.swift
Normal file
25
Plugins/Embedder/FileSystem.swift
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum FileSystem {
|
||||||
|
static func isDirectory(at url: URL) -> Bool {
|
||||||
|
let resourceValues = try? url.resourceValues(forKeys: [.isDirectoryKey])
|
||||||
|
return resourceValues?.isDirectory == true
|
||||||
|
}
|
||||||
|
|
||||||
|
static func regularFiles(under directory: URL) throws -> [URL] {
|
||||||
|
guard let enumerator = FileManager.default.enumerator(
|
||||||
|
at: directory,
|
||||||
|
includingPropertiesForKeys: [.isRegularFileKey],
|
||||||
|
options: [.skipsHiddenFiles]
|
||||||
|
) else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return enumerator
|
||||||
|
.compactMap { $0 as? URL }
|
||||||
|
.filter(isRegularFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isRegularFile(_ url: URL) -> Bool {
|
||||||
|
(try? url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true
|
||||||
|
}
|
||||||
|
}
|
||||||
89
README.md
Normal file
89
README.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# Embedder
|
||||||
|
|
||||||
|
A Swift Package Manager build-tool plugin that compiles every textual file in a
|
||||||
|
target's `Static Inline` subdirectory into a generated `Embedded` enum with
|
||||||
|
`static let` properties for each file.
|
||||||
|
|
||||||
|
The generated code embeds file contents as raw string literals at build time;
|
||||||
|
no bundle or runtime I/O is involved.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
1. Add this package as a dependency:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
.package(url: "https://github.com/astzweig/swiftpm-embedder", from: "1.0.0")
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Apply the plugin to your target and exclude the `Static Inline` directory
|
||||||
|
from the target's own source scan:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
.target(
|
||||||
|
name: "MyApp",
|
||||||
|
exclude: ["Static Inline"],
|
||||||
|
plugins: [
|
||||||
|
.plugin(name: "Embedder", package: "swiftpm-embedder")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Place your text assets under `Sources/<Target>/Static Inline/`:
|
||||||
|
|
||||||
|
```
|
||||||
|
Sources/<Target>/
|
||||||
|
MyApp.swift
|
||||||
|
Static Inline/
|
||||||
|
config.json
|
||||||
|
emails/
|
||||||
|
welcome.html
|
||||||
|
receipt.eml
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Reference the generated constants from your code:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
let welcomeBody: String = Embedded.Emails.welcomeHtml
|
||||||
|
let config: String = Embedded.configJson
|
||||||
|
```
|
||||||
|
|
||||||
|
## Generated shape
|
||||||
|
|
||||||
|
Given the tree above, the plugin produces:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
enum Embedded {
|
||||||
|
static let configJson: String = #"""
|
||||||
|
{"appName": "Sample"}
|
||||||
|
"""#
|
||||||
|
|
||||||
|
enum Emails {
|
||||||
|
static let receiptEml: String = #"""
|
||||||
|
Subject: Your receipt
|
||||||
|
"""#
|
||||||
|
|
||||||
|
static let welcomeHtml: String = #"""
|
||||||
|
<!doctype html>
|
||||||
|
...
|
||||||
|
"""#
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Subdirectories become nested enums with `UpperCamelCase` names; files become
|
||||||
|
`lowerCamelCase` `static let` properties. Filenames that start with a digit or
|
||||||
|
collide with Swift reserved words are escaped automatically.
|
||||||
|
|
||||||
|
## Allowed extensions
|
||||||
|
|
||||||
|
The plugin only embeds files whose extension is in a curated textual allow-list
|
||||||
|
(`json`, `yaml`, `yml`, `html`, `htm`, `eml`, `txt`, `md`, `markdown`, `xml`,
|
||||||
|
`csv`, `tsv`, `svg`, `css`, `js`, `mjs`, `sql`, `graphql`, `gql`, `toml`, `ini`,
|
||||||
|
`log`, `plist`, `jsonl`). Other files are ignored, so dropping an image or a
|
||||||
|
font into `Static Inline` is harmless.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- `swift-tools-version: 6.1` or newer
|
||||||
|
- macOS 13+, iOS 16+, tvOS 16+, or watchOS 9+ for packages that consume the
|
||||||
|
plugin
|
||||||
29
Sources/EmbedderTool/CommandLineInvocation.swift
Normal file
29
Sources/EmbedderTool/CommandLineInvocation.swift
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct CommandLineInvocation {
|
||||||
|
let sourceDirectory: URL
|
||||||
|
let outputFile: URL
|
||||||
|
}
|
||||||
|
|
||||||
|
extension CommandLineInvocation {
|
||||||
|
static func parse(_ arguments: [String]) throws -> CommandLineInvocation {
|
||||||
|
guard arguments.count == 3 else {
|
||||||
|
throw CommandLineInvocationError.wrongNumberOfArguments(received: arguments.count - 1)
|
||||||
|
}
|
||||||
|
return CommandLineInvocation(
|
||||||
|
sourceDirectory: URL(fileURLWithPath: arguments[1]),
|
||||||
|
outputFile: URL(fileURLWithPath: arguments[2])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CommandLineInvocationError: Error, CustomStringConvertible {
|
||||||
|
case wrongNumberOfArguments(received: Int)
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
switch self {
|
||||||
|
case .wrongNumberOfArguments(let received):
|
||||||
|
return "expected 2 arguments (source directory, output file); received \(received)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
Sources/EmbedderTool/EmbeddableFile.swift
Normal file
20
Sources/EmbedderTool/EmbeddableFile.swift
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct EmbeddableFile: Equatable {
|
||||||
|
let absoluteURL: URL
|
||||||
|
let relativePathComponents: [String]
|
||||||
|
}
|
||||||
|
|
||||||
|
extension EmbeddableFile {
|
||||||
|
var filename: String {
|
||||||
|
relativePathComponents.last ?? absoluteURL.lastPathComponent
|
||||||
|
}
|
||||||
|
|
||||||
|
var directoryComponents: [String] {
|
||||||
|
Array(relativePathComponents.dropLast())
|
||||||
|
}
|
||||||
|
|
||||||
|
var relativePath: String {
|
||||||
|
relativePathComponents.joined(separator: "/")
|
||||||
|
}
|
||||||
|
}
|
||||||
20
Sources/EmbedderTool/EmbedderTool.swift
Normal file
20
Sources/EmbedderTool/EmbedderTool.swift
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct EmbedderTool {
|
||||||
|
func run(invocation: CommandLineInvocation) throws {
|
||||||
|
let discoveredFiles = try FileDiscovery().discoverEmbeddableFiles(in: invocation.sourceDirectory)
|
||||||
|
let namespaceTree = NamespaceTreeBuilder().buildTree(from: discoveredFiles)
|
||||||
|
let generatedSource = try SwiftCodeGenerator().generate(from: namespaceTree)
|
||||||
|
try writeAtomically(generatedSource, to: invocation.outputFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func writeAtomically(_ contents: String, to file: URL) throws {
|
||||||
|
try createParentDirectoryIfNeeded(for: file)
|
||||||
|
try contents.write(to: file, atomically: true, encoding: .utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func createParentDirectoryIfNeeded(for file: URL) throws {
|
||||||
|
let parent = file.deletingLastPathComponent()
|
||||||
|
try FileManager.default.createDirectory(at: parent, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
18
Sources/EmbedderTool/EmbedderToolEntry.swift
Normal file
18
Sources/EmbedderTool/EmbedderToolEntry.swift
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
@main
|
||||||
|
enum EmbedderToolEntry {
|
||||||
|
static func main() {
|
||||||
|
do {
|
||||||
|
let invocation = try CommandLineInvocation.parse(CommandLine.arguments)
|
||||||
|
try EmbedderTool().run(invocation: invocation)
|
||||||
|
} catch {
|
||||||
|
reportFailure(error)
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func reportFailure(_ error: Error) {
|
||||||
|
FileHandle.standardError.write(Data("EmbedderTool: \(error)\n".utf8))
|
||||||
|
}
|
||||||
|
}
|
||||||
25
Sources/EmbedderTool/FileContentReader.swift
Normal file
25
Sources/EmbedderTool/FileContentReader.swift
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct FileContentReader {
|
||||||
|
func readTextContent(of file: EmbeddableFile) throws -> String {
|
||||||
|
do {
|
||||||
|
return try String(contentsOf: file.absoluteURL, encoding: .utf8)
|
||||||
|
} catch {
|
||||||
|
throw FileContentReaderError.couldNotDecodeAsUTF8(
|
||||||
|
path: file.absoluteURL.path,
|
||||||
|
underlying: error
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum FileContentReaderError: Error, CustomStringConvertible {
|
||||||
|
case couldNotDecodeAsUTF8(path: String, underlying: Error)
|
||||||
|
|
||||||
|
var description: String {
|
||||||
|
switch self {
|
||||||
|
case .couldNotDecodeAsUTF8(let path, let underlying):
|
||||||
|
return "failed to read \(path) as UTF-8: \(underlying.localizedDescription)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
49
Sources/EmbedderTool/FileDiscovery.swift
Normal file
49
Sources/EmbedderTool/FileDiscovery.swift
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct FileDiscovery {
|
||||||
|
func discoverEmbeddableFiles(in rootDirectory: URL) throws -> [EmbeddableFile] {
|
||||||
|
let rootComponents = standardizedComponents(of: rootDirectory)
|
||||||
|
return try enumerateRegularFiles(under: rootDirectory)
|
||||||
|
.filter(hasAllowedExtension)
|
||||||
|
.map { fileURL in
|
||||||
|
makeEmbeddableFile(at: fileURL, relativeToRootComponents: rootComponents)
|
||||||
|
}
|
||||||
|
.sorted(by: byRelativePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func enumerateRegularFiles(under directory: URL) throws -> [URL] {
|
||||||
|
guard let enumerator = FileManager.default.enumerator(
|
||||||
|
at: directory,
|
||||||
|
includingPropertiesForKeys: [.isRegularFileKey],
|
||||||
|
options: [.skipsHiddenFiles]
|
||||||
|
) else {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return enumerator.compactMap { $0 as? URL }.filter(isRegularFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isRegularFile(_ url: URL) -> Bool {
|
||||||
|
(try? url.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hasAllowedExtension(_ url: URL) -> Bool {
|
||||||
|
FileExtensionAllowList.permits(url.pathExtension)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeEmbeddableFile(
|
||||||
|
at fileURL: URL,
|
||||||
|
relativeToRootComponents rootComponents: [String]
|
||||||
|
) -> EmbeddableFile {
|
||||||
|
let fileComponents = standardizedComponents(of: fileURL)
|
||||||
|
let relativeComponents = Array(fileComponents.dropFirst(rootComponents.count))
|
||||||
|
return EmbeddableFile(absoluteURL: fileURL, relativePathComponents: relativeComponents)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func standardizedComponents(of url: URL) -> [String] {
|
||||||
|
url.standardizedFileURL.resolvingSymlinksInPath().pathComponents
|
||||||
|
}
|
||||||
|
|
||||||
|
private func byRelativePath(_ lhs: EmbeddableFile, _ rhs: EmbeddableFile) -> Bool {
|
||||||
|
lhs.relativePath.localizedStandardCompare(rhs.relativePath) == .orderedAscending
|
||||||
|
}
|
||||||
|
}
|
||||||
34
Sources/EmbedderTool/FileExtensionAllowList.swift
Normal file
34
Sources/EmbedderTool/FileExtensionAllowList.swift
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum FileExtensionAllowList {
|
||||||
|
static let textualExtensions: Set<String> = [
|
||||||
|
"css",
|
||||||
|
"csv",
|
||||||
|
"eml",
|
||||||
|
"gql",
|
||||||
|
"graphql",
|
||||||
|
"htm",
|
||||||
|
"html",
|
||||||
|
"ini",
|
||||||
|
"js",
|
||||||
|
"json",
|
||||||
|
"jsonl",
|
||||||
|
"log",
|
||||||
|
"markdown",
|
||||||
|
"md",
|
||||||
|
"mjs",
|
||||||
|
"plist",
|
||||||
|
"sql",
|
||||||
|
"svg",
|
||||||
|
"toml",
|
||||||
|
"tsv",
|
||||||
|
"txt",
|
||||||
|
"xml",
|
||||||
|
"yaml",
|
||||||
|
"yml"
|
||||||
|
]
|
||||||
|
|
||||||
|
static func permits(_ fileExtension: String) -> Bool {
|
||||||
|
textualExtensions.contains(fileExtension.lowercased())
|
||||||
|
}
|
||||||
|
}
|
||||||
102
Sources/EmbedderTool/IdentifierSanitizer.swift
Normal file
102
Sources/EmbedderTool/IdentifierSanitizer.swift
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum IdentifierSanitizer {
|
||||||
|
static func propertyName(fromFilename filename: String) -> String {
|
||||||
|
let words = words(from: filename)
|
||||||
|
let camelCased = joinAsLowerCamelCase(words)
|
||||||
|
return escapeForSwift(camelCased)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func typeName(from directoryName: String) -> String {
|
||||||
|
let words = words(from: directoryName)
|
||||||
|
let pascalCased = joinAsUpperCamelCase(words)
|
||||||
|
return escapeForSwift(pascalCased)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension IdentifierSanitizer {
|
||||||
|
static func words(from rawString: String) -> [String] {
|
||||||
|
let segments = splitOnNonAlphanumerics(rawString)
|
||||||
|
return segments.flatMap(splitCamelCaseBoundaries)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func splitOnNonAlphanumerics(_ string: String) -> [String] {
|
||||||
|
string
|
||||||
|
.split(whereSeparator: { !$0.isLetter && !$0.isNumber })
|
||||||
|
.map(String.init)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func splitCamelCaseBoundaries(_ word: String) -> [String] {
|
||||||
|
var results: [String] = []
|
||||||
|
var currentWord = ""
|
||||||
|
for character in word {
|
||||||
|
if shouldStartNewWord(at: character, currentWord: currentWord) {
|
||||||
|
results.append(currentWord)
|
||||||
|
currentWord = String(character)
|
||||||
|
} else {
|
||||||
|
currentWord.append(character)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !currentWord.isEmpty {
|
||||||
|
results.append(currentWord)
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
static func shouldStartNewWord(at character: Character, currentWord: String) -> Bool {
|
||||||
|
guard !currentWord.isEmpty, let lastCharacter = currentWord.last else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return lastCharacter.isLowercase && character.isUppercase
|
||||||
|
}
|
||||||
|
|
||||||
|
static func joinAsLowerCamelCase(_ words: [String]) -> String {
|
||||||
|
guard let firstWord = words.first else { return "" }
|
||||||
|
let remainingWords = words.dropFirst().map(capitalizeFirstLetter)
|
||||||
|
return firstWord.lowercased() + remainingWords.joined()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func joinAsUpperCamelCase(_ words: [String]) -> String {
|
||||||
|
words.map(capitalizeFirstLetter).joined()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func capitalizeFirstLetter(_ word: String) -> String {
|
||||||
|
guard let firstCharacter = word.first else { return "" }
|
||||||
|
return firstCharacter.uppercased() + word.dropFirst().lowercased()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func escapeForSwift(_ identifier: String) -> String {
|
||||||
|
let nonEmpty = fallbackIfEmpty(identifier)
|
||||||
|
let leadingDigitSafe = prefixIfStartsWithDigit(nonEmpty)
|
||||||
|
return wrapInBackticksIfReservedKeyword(leadingDigitSafe)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func fallbackIfEmpty(_ identifier: String) -> String {
|
||||||
|
identifier.isEmpty ? "_" : identifier
|
||||||
|
}
|
||||||
|
|
||||||
|
static func prefixIfStartsWithDigit(_ identifier: String) -> String {
|
||||||
|
guard let first = identifier.first, first.isNumber else {
|
||||||
|
return identifier
|
||||||
|
}
|
||||||
|
return "_" + identifier
|
||||||
|
}
|
||||||
|
|
||||||
|
static func wrapInBackticksIfReservedKeyword(_ identifier: String) -> String {
|
||||||
|
SwiftReservedKeywords.all.contains(identifier) ? "`\(identifier)`" : identifier
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum SwiftReservedKeywords {
|
||||||
|
static let all: Set<String> = [
|
||||||
|
"associatedtype", "class", "deinit", "enum", "extension", "fileprivate",
|
||||||
|
"func", "import", "init", "inout", "internal", "let", "open", "operator",
|
||||||
|
"private", "precedencegroup", "protocol", "public", "rethrows", "static",
|
||||||
|
"struct", "subscript", "typealias", "var",
|
||||||
|
"break", "case", "catch", "continue", "default", "defer", "do", "else",
|
||||||
|
"fallthrough", "for", "guard", "if", "in", "repeat", "return", "throw",
|
||||||
|
"switch", "where", "while",
|
||||||
|
"Any", "as", "false", "is", "nil", "self", "Self", "super", "throws",
|
||||||
|
"true", "try"
|
||||||
|
]
|
||||||
|
}
|
||||||
17
Sources/EmbedderTool/NamespaceNode.swift
Normal file
17
Sources/EmbedderTool/NamespaceNode.swift
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct NamespaceNode: Equatable {
|
||||||
|
var name: String
|
||||||
|
var files: [EmbeddableFile]
|
||||||
|
var subNamespaces: [NamespaceNode]
|
||||||
|
|
||||||
|
init(
|
||||||
|
name: String,
|
||||||
|
files: [EmbeddableFile] = [],
|
||||||
|
subNamespaces: [NamespaceNode] = []
|
||||||
|
) {
|
||||||
|
self.name = name
|
||||||
|
self.files = files
|
||||||
|
self.subNamespaces = subNamespaces
|
||||||
|
}
|
||||||
|
}
|
||||||
55
Sources/EmbedderTool/NamespaceTreeBuilder.swift
Normal file
55
Sources/EmbedderTool/NamespaceTreeBuilder.swift
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct NamespaceTreeBuilder {
|
||||||
|
static let rootNamespaceName = "Embedded"
|
||||||
|
|
||||||
|
func buildTree(from files: [EmbeddableFile]) -> NamespaceNode {
|
||||||
|
var root = NamespaceNode(name: Self.rootNamespaceName)
|
||||||
|
for file in files {
|
||||||
|
insertFile(file, into: &root)
|
||||||
|
}
|
||||||
|
sortRecursively(&root)
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
private func insertFile(_ file: EmbeddableFile, into root: inout NamespaceNode) {
|
||||||
|
placeFile(file, atRemainingPath: file.directoryComponents, within: &root)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func placeFile(
|
||||||
|
_ file: EmbeddableFile,
|
||||||
|
atRemainingPath remainingPath: [String],
|
||||||
|
within node: inout NamespaceNode
|
||||||
|
) {
|
||||||
|
guard let nextDirectoryName = remainingPath.first else {
|
||||||
|
node.files.append(file)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let childName = IdentifierSanitizer.typeName(from: nextDirectoryName)
|
||||||
|
let deeperPath = Array(remainingPath.dropFirst())
|
||||||
|
placeFile(file, atPath: deeperPath, inChildNamed: childName, of: &node)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func placeFile(
|
||||||
|
_ file: EmbeddableFile,
|
||||||
|
atPath deeperPath: [String],
|
||||||
|
inChildNamed childName: String,
|
||||||
|
of parent: inout NamespaceNode
|
||||||
|
) {
|
||||||
|
if let existingIndex = parent.subNamespaces.firstIndex(where: { $0.name == childName }) {
|
||||||
|
placeFile(file, atRemainingPath: deeperPath, within: &parent.subNamespaces[existingIndex])
|
||||||
|
} else {
|
||||||
|
var freshChild = NamespaceNode(name: childName)
|
||||||
|
placeFile(file, atRemainingPath: deeperPath, within: &freshChild)
|
||||||
|
parent.subNamespaces.append(freshChild)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sortRecursively(_ node: inout NamespaceNode) {
|
||||||
|
node.files.sort { $0.filename.localizedStandardCompare($1.filename) == .orderedAscending }
|
||||||
|
node.subNamespaces.sort { $0.name < $1.name }
|
||||||
|
for index in node.subNamespaces.indices {
|
||||||
|
sortRecursively(&node.subNamespaces[index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
Sources/EmbedderTool/StringLiteralEscaper.swift
Normal file
48
Sources/EmbedderTool/StringLiteralEscaper.swift
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum StringLiteralEscaper {
|
||||||
|
static func rawTripleQuotedLiteral(from content: String) -> String {
|
||||||
|
let hashDelimiter = makeHashDelimiter(avoidingCollisionsIn: content)
|
||||||
|
return assembleLiteral(content: content, hashDelimiter: hashDelimiter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension StringLiteralEscaper {
|
||||||
|
static let tripleQuote = "\"\"\""
|
||||||
|
|
||||||
|
static func assembleLiteral(content: String, hashDelimiter: String) -> String {
|
||||||
|
let opening = "\(hashDelimiter)\(tripleQuote)\n"
|
||||||
|
let closing = "\n\(tripleQuote)\(hashDelimiter)"
|
||||||
|
return opening + content + closing
|
||||||
|
}
|
||||||
|
|
||||||
|
static func makeHashDelimiter(avoidingCollisionsIn content: String) -> String {
|
||||||
|
let minimumHashCount = longestHashRunFollowingTripleQuote(in: content) + 1
|
||||||
|
return String(repeating: "#", count: minimumHashCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func longestHashRunFollowingTripleQuote(in content: String) -> Int {
|
||||||
|
var longestRun = 0
|
||||||
|
var cursor = content.startIndex
|
||||||
|
while let tripleQuoteRange = content.range(of: tripleQuote, range: cursor..<content.endIndex) {
|
||||||
|
let hashRun = consecutiveHashCount(in: content, startingAt: tripleQuoteRange.upperBound)
|
||||||
|
longestRun = max(longestRun, hashRun)
|
||||||
|
cursor = advance(from: tripleQuoteRange.lowerBound, in: content)
|
||||||
|
}
|
||||||
|
return longestRun
|
||||||
|
}
|
||||||
|
|
||||||
|
static func consecutiveHashCount(in content: String, startingAt startIndex: String.Index) -> Int {
|
||||||
|
var count = 0
|
||||||
|
var index = startIndex
|
||||||
|
while index < content.endIndex, content[index] == "#" {
|
||||||
|
count += 1
|
||||||
|
index = content.index(after: index)
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
static func advance(from index: String.Index, in content: String) -> String.Index {
|
||||||
|
content.index(after: index)
|
||||||
|
}
|
||||||
|
}
|
||||||
77
Sources/EmbedderTool/SwiftCodeGenerator.swift
Normal file
77
Sources/EmbedderTool/SwiftCodeGenerator.swift
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct SwiftCodeGenerator {
|
||||||
|
private let fileContentReader: FileContentReader
|
||||||
|
private let indentationUnit: String
|
||||||
|
|
||||||
|
init(
|
||||||
|
fileContentReader: FileContentReader = FileContentReader(),
|
||||||
|
indentationUnit: String = " "
|
||||||
|
) {
|
||||||
|
self.fileContentReader = fileContentReader
|
||||||
|
self.indentationUnit = indentationUnit
|
||||||
|
}
|
||||||
|
|
||||||
|
func generate(from rootNamespace: NamespaceNode) throws -> String {
|
||||||
|
generatedFileBanner() + (try renderNamespace(rootNamespace, depth: 0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension SwiftCodeGenerator {
|
||||||
|
func generatedFileBanner() -> String {
|
||||||
|
"""
|
||||||
|
// Generated by the Embedder plugin from the "Static Inline" directory.
|
||||||
|
// Any manual changes will be overwritten on the next build.
|
||||||
|
|
||||||
|
""" + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderNamespace(_ namespace: NamespaceNode, depth: Int) throws -> String {
|
||||||
|
let openingLine = namespaceOpeningLine(for: namespace, depth: depth)
|
||||||
|
let bodyLines = try renderBody(of: namespace, depth: depth + 1)
|
||||||
|
let closingLine = namespaceClosingLine(depth: depth)
|
||||||
|
return openingLine + bodyLines + closingLine
|
||||||
|
}
|
||||||
|
|
||||||
|
func namespaceOpeningLine(for namespace: NamespaceNode, depth: Int) -> String {
|
||||||
|
"\(indent(depth: depth))enum \(namespace.name) {\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
func namespaceClosingLine(depth: Int) -> String {
|
||||||
|
"\(indent(depth: depth))}\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderBody(of namespace: NamespaceNode, depth: Int) throws -> String {
|
||||||
|
let fileDeclarations = try namespace.files.map { try renderFileDeclaration($0, depth: depth) }
|
||||||
|
let nestedNamespaces = try namespace.subNamespaces.map { try renderNamespace($0, depth: depth) }
|
||||||
|
return joinWithBlankLines(fileDeclarations + nestedNamespaces)
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderFileDeclaration(_ file: EmbeddableFile, depth: Int) throws -> String {
|
||||||
|
let propertyName = IdentifierSanitizer.propertyName(fromFilename: file.filename)
|
||||||
|
let literal = try renderStringLiteral(for: file, baseIndent: indent(depth: depth))
|
||||||
|
return "\(indent(depth: depth))static let \(propertyName): String = \(literal)\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderStringLiteral(for file: EmbeddableFile, baseIndent: String) throws -> String {
|
||||||
|
let content = try fileContentReader.readTextContent(of: file)
|
||||||
|
let rawLiteral = StringLiteralEscaper.rawTripleQuotedLiteral(from: content)
|
||||||
|
return indentContinuationLines(of: rawLiteral, by: baseIndent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func indentContinuationLines(of literal: String, by baseIndent: String) -> String {
|
||||||
|
let lines = literal.components(separatedBy: "\n")
|
||||||
|
guard lines.count > 1 else { return literal }
|
||||||
|
let firstLine = lines[0]
|
||||||
|
let continuationLines = lines.dropFirst().map { "\(baseIndent)\($0)" }
|
||||||
|
return ([firstLine] + continuationLines).joined(separator: "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func joinWithBlankLines(_ declarations: [String]) -> String {
|
||||||
|
declarations.joined(separator: "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func indent(depth: Int) -> String {
|
||||||
|
String(repeating: indentationUnit, count: depth)
|
||||||
|
}
|
||||||
|
}
|
||||||
32
Tests/EmbedderToolTests/FileExtensionAllowListTests.swift
Normal file
32
Tests/EmbedderToolTests/FileExtensionAllowListTests.swift
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import Testing
|
||||||
|
@testable import EmbedderTool
|
||||||
|
|
||||||
|
@Suite("FileExtensionAllowList") struct FileExtensionAllowListTests {
|
||||||
|
|
||||||
|
@Test("permits common textual formats")
|
||||||
|
func permitsTextual() {
|
||||||
|
let textual = ["json", "yaml", "yml", "html", "eml", "txt", "md", "xml", "csv", "svg"]
|
||||||
|
for fileExtension in textual {
|
||||||
|
#expect(FileExtensionAllowList.permits(fileExtension))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("ignores letter casing")
|
||||||
|
func caseInsensitive() {
|
||||||
|
#expect(FileExtensionAllowList.permits("JSON"))
|
||||||
|
#expect(FileExtensionAllowList.permits("Html"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("rejects known binary extensions")
|
||||||
|
func rejectsBinary() {
|
||||||
|
let binary = ["png", "jpg", "jpeg", "pdf", "zip", "gif", "ttf", "woff", "mp3", "mp4"]
|
||||||
|
for fileExtension in binary {
|
||||||
|
#expect(!FileExtensionAllowList.permits(fileExtension))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("rejects files without an extension")
|
||||||
|
func rejectsMissingExtension() {
|
||||||
|
#expect(!FileExtensionAllowList.permits(""))
|
||||||
|
}
|
||||||
|
}
|
||||||
53
Tests/EmbedderToolTests/IdentifierSanitizerTests.swift
Normal file
53
Tests/EmbedderToolTests/IdentifierSanitizerTests.swift
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import Testing
|
||||||
|
@testable import EmbedderTool
|
||||||
|
|
||||||
|
@Suite("IdentifierSanitizer") struct IdentifierSanitizerTests {
|
||||||
|
|
||||||
|
@Test("converts a simple filename with extension to lowerCamelCase")
|
||||||
|
func simpleFilename() {
|
||||||
|
#expect(IdentifierSanitizer.propertyName(fromFilename: "welcome.html") == "welcomeHtml")
|
||||||
|
#expect(IdentifierSanitizer.propertyName(fromFilename: "config.json") == "configJson")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("normalizes uppercase and mixed case extensions")
|
||||||
|
func uppercaseExtension() {
|
||||||
|
#expect(IdentifierSanitizer.propertyName(fromFilename: "welcome.HTML") == "welcomeHtml")
|
||||||
|
#expect(IdentifierSanitizer.propertyName(fromFilename: "Data.Yaml") == "dataYaml")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("splits snake_case, kebab-case and camelCase into words")
|
||||||
|
func splitWords() {
|
||||||
|
#expect(IdentifierSanitizer.propertyName(fromFilename: "user_profile.json") == "userProfileJson")
|
||||||
|
#expect(IdentifierSanitizer.propertyName(fromFilename: "email-template.eml") == "emailTemplateEml")
|
||||||
|
#expect(IdentifierSanitizer.propertyName(fromFilename: "fooBar.json") == "fooBarJson")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("prefixes an underscore when a filename starts with a digit")
|
||||||
|
func digitPrefix() {
|
||||||
|
#expect(IdentifierSanitizer.propertyName(fromFilename: "404.html") == "_404Html")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("escapes Swift reserved keywords with backticks")
|
||||||
|
func reservedKeyword() {
|
||||||
|
#expect(IdentifierSanitizer.propertyName(fromFilename: "class") == "`class`")
|
||||||
|
#expect(IdentifierSanitizer.propertyName(fromFilename: "return") == "`return`")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("collapses runs of non-alphanumeric characters")
|
||||||
|
func multipleSeparators() {
|
||||||
|
#expect(IdentifierSanitizer.propertyName(fromFilename: "user--profile__v2.json") == "userProfileV2Json")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("produces UpperCamelCase type names for directories")
|
||||||
|
func typeNames() {
|
||||||
|
#expect(IdentifierSanitizer.typeName(from: "emails") == "Emails")
|
||||||
|
#expect(IdentifierSanitizer.typeName(from: "user-templates") == "UserTemplates")
|
||||||
|
#expect(IdentifierSanitizer.typeName(from: "api_v2") == "ApiV2")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("returns an underscore fallback when the identifier would be empty")
|
||||||
|
func emptyFallback() {
|
||||||
|
#expect(IdentifierSanitizer.propertyName(fromFilename: "---") == "_")
|
||||||
|
#expect(IdentifierSanitizer.typeName(from: "") == "_")
|
||||||
|
}
|
||||||
|
}
|
||||||
67
Tests/EmbedderToolTests/NamespaceTreeBuilderTests.swift
Normal file
67
Tests/EmbedderToolTests/NamespaceTreeBuilderTests.swift
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import EmbedderTool
|
||||||
|
|
||||||
|
@Suite("NamespaceTreeBuilder") struct NamespaceTreeBuilderTests {
|
||||||
|
|
||||||
|
@Test("places top-level files directly inside the root namespace")
|
||||||
|
func topLevelFiles() {
|
||||||
|
let files = [
|
||||||
|
makeFile(relativePath: "config.json"),
|
||||||
|
makeFile(relativePath: "welcome.html")
|
||||||
|
]
|
||||||
|
let tree = NamespaceTreeBuilder().buildTree(from: files)
|
||||||
|
|
||||||
|
#expect(tree.name == "Embedded")
|
||||||
|
#expect(tree.files.map(\.filename) == ["config.json", "welcome.html"])
|
||||||
|
#expect(tree.subNamespaces.isEmpty)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("nests subdirectories as child enums")
|
||||||
|
func nestedDirectories() {
|
||||||
|
let files = [
|
||||||
|
makeFile(relativePath: "emails/welcome.html"),
|
||||||
|
makeFile(relativePath: "emails/receipt.eml"),
|
||||||
|
makeFile(relativePath: "root.json")
|
||||||
|
]
|
||||||
|
let tree = NamespaceTreeBuilder().buildTree(from: files)
|
||||||
|
|
||||||
|
#expect(tree.files.map(\.filename) == ["root.json"])
|
||||||
|
#expect(tree.subNamespaces.count == 1)
|
||||||
|
#expect(tree.subNamespaces[0].name == "Emails")
|
||||||
|
#expect(tree.subNamespaces[0].files.map(\.filename) == ["receipt.eml", "welcome.html"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("merges multiple files under the same sanitized directory name")
|
||||||
|
func mergesSiblingNamespaces() {
|
||||||
|
let files = [
|
||||||
|
makeFile(relativePath: "user-templates/a.json"),
|
||||||
|
makeFile(relativePath: "user-templates/b.json")
|
||||||
|
]
|
||||||
|
let tree = NamespaceTreeBuilder().buildTree(from: files)
|
||||||
|
|
||||||
|
#expect(tree.subNamespaces.count == 1)
|
||||||
|
#expect(tree.subNamespaces[0].name == "UserTemplates")
|
||||||
|
#expect(tree.subNamespaces[0].files.count == 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("preserves deep hierarchies")
|
||||||
|
func deepHierarchy() {
|
||||||
|
let files = [
|
||||||
|
makeFile(relativePath: "api/v2/users/list.json")
|
||||||
|
]
|
||||||
|
let tree = NamespaceTreeBuilder().buildTree(from: files)
|
||||||
|
|
||||||
|
#expect(tree.subNamespaces.count == 1)
|
||||||
|
#expect(tree.subNamespaces[0].name == "Api")
|
||||||
|
#expect(tree.subNamespaces[0].subNamespaces[0].name == "V2")
|
||||||
|
#expect(tree.subNamespaces[0].subNamespaces[0].subNamespaces[0].name == "Users")
|
||||||
|
#expect(tree.subNamespaces[0].subNamespaces[0].subNamespaces[0].files.map(\.filename) == ["list.json"])
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeFile(relativePath: String) -> EmbeddableFile {
|
||||||
|
let components = relativePath.split(separator: "/").map(String.init)
|
||||||
|
let absoluteURL = URL(fileURLWithPath: "/fake/Static Inline").appending(path: relativePath)
|
||||||
|
return EmbeddableFile(absoluteURL: absoluteURL, relativePathComponents: components)
|
||||||
|
}
|
||||||
|
}
|
||||||
37
Tests/EmbedderToolTests/StringLiteralEscaperTests.swift
Normal file
37
Tests/EmbedderToolTests/StringLiteralEscaperTests.swift
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import Testing
|
||||||
|
@testable import EmbedderTool
|
||||||
|
|
||||||
|
@Suite("StringLiteralEscaper") struct StringLiteralEscaperTests {
|
||||||
|
|
||||||
|
@Test("wraps plain content with a single hash delimiter")
|
||||||
|
func plainContent() {
|
||||||
|
let literal = StringLiteralEscaper.rawTripleQuotedLiteral(from: "hello")
|
||||||
|
#expect(literal == "#\"\"\"\nhello\n\"\"\"#")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("uses two hashes when content contains a triple-quote followed by one hash")
|
||||||
|
func escalatesForSingleHashCollision() {
|
||||||
|
let literal = StringLiteralEscaper.rawTripleQuotedLiteral(from: "before\"\"\"#after")
|
||||||
|
#expect(literal == "##\"\"\"\nbefore\"\"\"#after\n\"\"\"##")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("escalates hash count past the longest run seen in content")
|
||||||
|
func escalatesForLongerHashRun() {
|
||||||
|
let literal = StringLiteralEscaper.rawTripleQuotedLiteral(from: "x\"\"\"####y")
|
||||||
|
#expect(literal.hasPrefix("#####\"\"\""))
|
||||||
|
#expect(literal.hasSuffix("\"\"\"#####"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("accepts empty content")
|
||||||
|
func emptyContent() {
|
||||||
|
let literal = StringLiteralEscaper.rawTripleQuotedLiteral(from: "")
|
||||||
|
#expect(literal == "#\"\"\"\n\n\"\"\"#")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("leaves triple-quotes without trailing hashes untouched")
|
||||||
|
func tripleQuotesWithoutHashes() {
|
||||||
|
let literal = StringLiteralEscaper.rawTripleQuotedLiteral(from: "a\"\"\"b")
|
||||||
|
#expect(literal.hasPrefix("#\"\"\""))
|
||||||
|
#expect(literal.hasSuffix("\"\"\"#"))
|
||||||
|
}
|
||||||
|
}
|
||||||
79
Tests/EmbedderToolTests/SwiftCodeGeneratorTests.swift
Normal file
79
Tests/EmbedderToolTests/SwiftCodeGeneratorTests.swift
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
@testable import EmbedderTool
|
||||||
|
|
||||||
|
@Suite("SwiftCodeGenerator") struct SwiftCodeGeneratorTests {
|
||||||
|
|
||||||
|
@Test("generates a compilable file for top-level files")
|
||||||
|
func topLevelFiles() throws {
|
||||||
|
let directory = try TemporaryDirectory()
|
||||||
|
let configFile = try directory.write(contents: #"{"key":"value"}"#, toRelativePath: "config.json")
|
||||||
|
|
||||||
|
let root = NamespaceNode(
|
||||||
|
name: "Embedded",
|
||||||
|
files: [
|
||||||
|
EmbeddableFile(
|
||||||
|
absoluteURL: configFile,
|
||||||
|
relativePathComponents: ["config.json"]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
let output = try SwiftCodeGenerator().generate(from: root)
|
||||||
|
|
||||||
|
#expect(output.contains("enum Embedded {"))
|
||||||
|
#expect(output.contains("static let configJson: String = #\"\"\""))
|
||||||
|
#expect(output.contains(#"{"key":"value"}"#))
|
||||||
|
#expect(output.contains("\"\"\"#"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("nests child enums for subdirectories")
|
||||||
|
func nestedNamespaces() throws {
|
||||||
|
let directory = try TemporaryDirectory()
|
||||||
|
let welcomeFile = try directory.write(contents: "<html></html>", toRelativePath: "emails/welcome.html")
|
||||||
|
|
||||||
|
let root = NamespaceNode(
|
||||||
|
name: "Embedded",
|
||||||
|
subNamespaces: [
|
||||||
|
NamespaceNode(
|
||||||
|
name: "Emails",
|
||||||
|
files: [
|
||||||
|
EmbeddableFile(
|
||||||
|
absoluteURL: welcomeFile,
|
||||||
|
relativePathComponents: ["emails", "welcome.html"]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
let output = try SwiftCodeGenerator().generate(from: root)
|
||||||
|
|
||||||
|
#expect(output.contains("enum Embedded {"))
|
||||||
|
#expect(output.contains("enum Emails {"))
|
||||||
|
#expect(output.contains("static let welcomeHtml: String = #\"\"\""))
|
||||||
|
#expect(output.contains("<html></html>"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test("includes a generated-file header")
|
||||||
|
func header() throws {
|
||||||
|
let output = try SwiftCodeGenerator().generate(from: NamespaceNode(name: "Embedded"))
|
||||||
|
#expect(output.hasPrefix("// Generated by the Embedder plugin"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct TemporaryDirectory {
|
||||||
|
let url: URL
|
||||||
|
|
||||||
|
init() throws {
|
||||||
|
let baseURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
|
||||||
|
self.url = baseURL.appending(path: "EmbedderToolTests-\(UUID().uuidString)")
|
||||||
|
try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func write(contents: String, toRelativePath relativePath: String) throws -> URL {
|
||||||
|
let fileURL = url.appending(path: relativePath)
|
||||||
|
let parent = fileURL.deletingLastPathComponent()
|
||||||
|
try FileManager.default.createDirectory(at: parent, withIntermediateDirectories: true)
|
||||||
|
try contents.write(to: fileURL, atomically: true, encoding: .utf8)
|
||||||
|
return fileURL
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user