commit b49d642dfd9382ed006a0fcefb794efd0e6442e9 Author: T. R. Bernstein Date: Fri Apr 17 01:08:29 2026 +0200 Scaffold v1.0.0 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1666ac6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.build +public diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..f4d5af4 --- /dev/null +++ b/Package.swift @@ -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" + ) + ] +) diff --git a/Plugins/Embedder/EmbedderPlugin.swift b/Plugins/Embedder/EmbedderPlugin.swift new file mode 100644 index 0000000..aa692cf --- /dev/null +++ b/Plugins/Embedder/EmbedderPlugin.swift @@ -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] + ) + } +} diff --git a/Plugins/Embedder/FileSystem.swift b/Plugins/Embedder/FileSystem.swift new file mode 100644 index 0000000..03ab397 --- /dev/null +++ b/Plugins/Embedder/FileSystem.swift @@ -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 + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..ef13094 --- /dev/null +++ b/README.md @@ -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//Static Inline/`: + + ``` + Sources// + 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 = #""" + + ... + """# + } +} +``` + +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 diff --git a/Sources/EmbedderTool/CommandLineInvocation.swift b/Sources/EmbedderTool/CommandLineInvocation.swift new file mode 100644 index 0000000..d06891f --- /dev/null +++ b/Sources/EmbedderTool/CommandLineInvocation.swift @@ -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)" + } + } +} diff --git a/Sources/EmbedderTool/EmbeddableFile.swift b/Sources/EmbedderTool/EmbeddableFile.swift new file mode 100644 index 0000000..de3328f --- /dev/null +++ b/Sources/EmbedderTool/EmbeddableFile.swift @@ -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: "/") + } +} diff --git a/Sources/EmbedderTool/EmbedderTool.swift b/Sources/EmbedderTool/EmbedderTool.swift new file mode 100644 index 0000000..7a35ba6 --- /dev/null +++ b/Sources/EmbedderTool/EmbedderTool.swift @@ -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) + } +} diff --git a/Sources/EmbedderTool/EmbedderToolEntry.swift b/Sources/EmbedderTool/EmbedderToolEntry.swift new file mode 100644 index 0000000..9b52f7e --- /dev/null +++ b/Sources/EmbedderTool/EmbedderToolEntry.swift @@ -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)) + } +} diff --git a/Sources/EmbedderTool/FileContentReader.swift b/Sources/EmbedderTool/FileContentReader.swift new file mode 100644 index 0000000..38c7649 --- /dev/null +++ b/Sources/EmbedderTool/FileContentReader.swift @@ -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)" + } + } +} diff --git a/Sources/EmbedderTool/FileDiscovery.swift b/Sources/EmbedderTool/FileDiscovery.swift new file mode 100644 index 0000000..88a7dda --- /dev/null +++ b/Sources/EmbedderTool/FileDiscovery.swift @@ -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 + } +} diff --git a/Sources/EmbedderTool/FileExtensionAllowList.swift b/Sources/EmbedderTool/FileExtensionAllowList.swift new file mode 100644 index 0000000..2707c29 --- /dev/null +++ b/Sources/EmbedderTool/FileExtensionAllowList.swift @@ -0,0 +1,34 @@ +import Foundation + +enum FileExtensionAllowList { + static let textualExtensions: Set = [ + "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()) + } +} diff --git a/Sources/EmbedderTool/IdentifierSanitizer.swift b/Sources/EmbedderTool/IdentifierSanitizer.swift new file mode 100644 index 0000000..09bf62c --- /dev/null +++ b/Sources/EmbedderTool/IdentifierSanitizer.swift @@ -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 = [ + "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" + ] +} diff --git a/Sources/EmbedderTool/NamespaceNode.swift b/Sources/EmbedderTool/NamespaceNode.swift new file mode 100644 index 0000000..b13cad4 --- /dev/null +++ b/Sources/EmbedderTool/NamespaceNode.swift @@ -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 + } +} diff --git a/Sources/EmbedderTool/NamespaceTreeBuilder.swift b/Sources/EmbedderTool/NamespaceTreeBuilder.swift new file mode 100644 index 0000000..41d047d --- /dev/null +++ b/Sources/EmbedderTool/NamespaceTreeBuilder.swift @@ -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]) + } + } +} diff --git a/Sources/EmbedderTool/StringLiteralEscaper.swift b/Sources/EmbedderTool/StringLiteralEscaper.swift new file mode 100644 index 0000000..dbd64b3 --- /dev/null +++ b/Sources/EmbedderTool/StringLiteralEscaper.swift @@ -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.. 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) + } +} diff --git a/Sources/EmbedderTool/SwiftCodeGenerator.swift b/Sources/EmbedderTool/SwiftCodeGenerator.swift new file mode 100644 index 0000000..184d987 --- /dev/null +++ b/Sources/EmbedderTool/SwiftCodeGenerator.swift @@ -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) + } +} diff --git a/Tests/EmbedderToolTests/FileExtensionAllowListTests.swift b/Tests/EmbedderToolTests/FileExtensionAllowListTests.swift new file mode 100644 index 0000000..2ad226f --- /dev/null +++ b/Tests/EmbedderToolTests/FileExtensionAllowListTests.swift @@ -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("")) + } +} diff --git a/Tests/EmbedderToolTests/IdentifierSanitizerTests.swift b/Tests/EmbedderToolTests/IdentifierSanitizerTests.swift new file mode 100644 index 0000000..cea1edf --- /dev/null +++ b/Tests/EmbedderToolTests/IdentifierSanitizerTests.swift @@ -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: "") == "_") + } +} diff --git a/Tests/EmbedderToolTests/NamespaceTreeBuilderTests.swift b/Tests/EmbedderToolTests/NamespaceTreeBuilderTests.swift new file mode 100644 index 0000000..4ecd7de --- /dev/null +++ b/Tests/EmbedderToolTests/NamespaceTreeBuilderTests.swift @@ -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) + } +} diff --git a/Tests/EmbedderToolTests/StringLiteralEscaperTests.swift b/Tests/EmbedderToolTests/StringLiteralEscaperTests.swift new file mode 100644 index 0000000..445d2ca --- /dev/null +++ b/Tests/EmbedderToolTests/StringLiteralEscaperTests.swift @@ -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("\"\"\"#")) + } +} diff --git a/Tests/EmbedderToolTests/SwiftCodeGeneratorTests.swift b/Tests/EmbedderToolTests/SwiftCodeGeneratorTests.swift new file mode 100644 index 0000000..4bd0627 --- /dev/null +++ b/Tests/EmbedderToolTests/SwiftCodeGeneratorTests.swift @@ -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: "", 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("")) + } + + @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 + } +}