Scaffold v1.0.0
This commit is contained in:
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user