Skip to content

Commit

Permalink
Merge pull request #8 from rpwachowski/generate-strings
Browse files Browse the repository at this point in the history
Generate strings
  • Loading branch information
Ryan Wachowski authored Oct 20, 2020
2 parents 14e3e73 + 347918c commit 7fba800
Show file tree
Hide file tree
Showing 13 changed files with 363 additions and 73 deletions.
6 changes: 6 additions & 0 deletions Sources/Hexiconjuror/Commands/ConjurorError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import Foundation

enum ConjurorError: String, Error {
case invalidArguments
case invalidDiff = "The supplied diff is not valid"
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ final class GenerateDiff: ConjurorCommand {

static func evaluate(_ m: CommandMode) -> Result<GenerateDiff.Options, CommandantError<ConjurorError>> {
create
<*> m <| Option(key: "defPath", defaultValue: "", usage: "Relative path to the files which contain the string definition namespaces.")
<*> m <| Option(key: "scanPath", defaultValue: nil, usage: "Relative path to the files which should be scanned for string usages.")
<*> m <| Option(key: "def-path", defaultValue: "", usage: "Relative path to the files which contain the string definition namespaces.")
<*> m <| Option(key: "scan-path", defaultValue: nil, usage: "Relative path to the files which should be scanned for string usages.")
}

let definitionsPath: String
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ final class GenerateNamespace: ConjurorCommand {
static func evaluate(_ m: CommandMode) -> Result<GenerateNamespace.Options, CommandantError<ConjurorError>> {
create
<*> m <| Option(key: "name", defaultValue: "", usage: "The name of the new namespace. Required.")
<*> m <| Option(key: "outputPath", defaultValue: nil, usage: "The path to the new file. If the path does not end in a file name, the file name 'Strings.swift' is used.")
<*> m <| Option(key: "output-path", defaultValue: nil, usage: "The path to the new file. If the path does not end in a file name, the file name 'Strings.swift' is used.")
}

}
Expand Down
221 changes: 221 additions & 0 deletions Sources/Hexiconjuror/Commands/Output Strings/OutputStrings.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import Commandant
import Foundation

final class OutputStrings: ConjurorCommand {
typealias ClientError = ConjurorError

struct Options: OptionsProtocol {
typealias ClientError = ConjurorError

static func create(_ definitionsPath: String) -> (String) -> (Bool) -> Options {
{ resourcesPath in { Options(definitionsPath: definitionsPath, resourcesPath: resourcesPath, stripEmptyComments: $0) } }
}

static func evaluate(_ m: CommandMode) -> Result<OutputStrings.Options, CommandantError<ConjurorError>> {
create
<*> m <| Option(key: "def-path", defaultValue: "", usage: "Relative path to the files which contain the string definition namespaces.")
<*> m <| Option(key: "res-path", defaultValue: "", usage: "Relative path to the strings files.")
<*> m <| Switch(key: "strip-comments", usage: "Whether to remove from the output file which have no engineer-provided comment.")
}

let definitionsPath: String
let resourcesPath: String
let stripEmptyComments: Bool

}

let verb = "output-strings"
let function = "Outputs and sorts all localized strings into their respective tables, preserving existing and deleting unused translations."
private let parser = StringsParser()

func run(_ options: OutputStrings.Options) -> Result<(), ConjurorError> {
let sourcePath = options.definitionsPath.isEmpty ? environment.projectPath : environment.projectPath.appendingPathComponent(options.definitionsPath)
let sourceFiles = FileManager.default.enumerator(at: sourcePath, includingPropertiesForKeys: [.isRegularFileKey])?
.compactMap { $0 as? URL }
.filter { $0.pathExtension == "swift" } ?? []
let resourcesPath = options.resourcesPath.isEmpty ? environment.projectPath : environment.projectPath.appendingPathComponent(options.resourcesPath)
let stringsFiles = FileManager.default.enumerator(at: resourcesPath, includingPropertiesForKeys: [.isRegularFileKey])?
.compactMap { $0 as? URL }
.filter { $0.pathExtension == "strings" } ?? []
let temporaryDirectory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
return Result {
try FileManager.default.createDirectory(atPath: temporaryDirectory.path, withIntermediateDirectories: false, attributes: nil)
}
.flatMapCatching { files in
let localize = Process()
let arguments = "/usr/bin/xcrun extractLocStrings -o \(temporaryDirectory.path)"
.split(separator: " ").map(String.init)
+ sourceFiles.map { $0.path }
localize.executableURL = URL(fileURLWithPath: arguments[0])
localize.arguments = Array(arguments.dropFirst())
try localize.run()
localize.waitUntilExit()
}
.flatMapCatching {
let existingTables = try stringsFiles.map {
try Table(url: $0, strings: parser.parse(file: $0))
}
let newTables = try FileManager.default.contentsOfDirectory(at: temporaryDirectory, includingPropertiesForKeys: [.isRegularFileKey], options: [])
.filter { $0.pathExtension == "strings" }
.map { return try Table(url: $0, strings: parser.parse(file: $0)) }
return Array(newTables.map { newTable -> [Table] in
let matches = existingTables.filter { $0.name == newTable.name }
guard !matches.isEmpty else {
return [mutate(newTable) { $0.url = resourcesPath.appendingPathComponent("\($0.name).strings") }]
}
return matches.map {
var matchingTable = $0
newTable.strings.forEach { newString in
guard var string = matchingTable.strings[newString.key] else {
matchingTable.strings[newString.key] = newString.value
return
}
if string.hasEmptyComment {
string.comment = newString.value.comment
}
matchingTable.strings[newString.key] = string
}
matchingTable.strings = matchingTable.strings.filter {
newTable.strings[$0.key] != nil
}
return matchingTable
}
}
.joined())
}
.flatMapCatching { (tables: [Table]) in
try tables.forEach { table in
let oldFile = temporaryDirectory.appendingPathComponent(UUID().uuidString)
let tempFile = temporaryDirectory.appendingPathComponent(UUID().uuidString)
FileManager.default.createFile(atPath: tempFile.path, contents: nil, attributes: nil)
try table.write(to: tempFile, stripEmptyComments: options.stripEmptyComments)
if FileManager.default.fileExists(atPath: table.url.path) {
try FileManager.default.moveItem(at: table.url, to: oldFile)
}
do {
try FileManager.default.moveItem(at: tempFile, to: table.url)
} catch {
try FileManager.default.moveItem(at: oldFile, to: table.url)
}
}
}
.mapError { _ in ConjurorError.invalidArguments }
}


}

extension OutputStrings {

private struct Table {

var url: URL
var strings: [String: LocalizedString]

var name: String { url.resourceName }

func write(to url: URL, stripEmptyComments: Bool) throws {
var stream = try FileOutputStream(url: url)
strings.sorted(by: \.key).map(\.value).map {
(($0.hasEmptyComment && stripEmptyComments ? [] : [$0.comment]) + [#""\#($0.key)" = "\#($0.value)";"#, "\n"]).joined(separator: "\n")
}
.joined()
.write(to: &stream)
}

}

private struct LocalizedString {

static var emptyComment: String { "/* No comment provided by engineer. */" }

var comment: String
var key: String
var value: String

var hasEmptyComment: Bool {
comment == type(of: self).emptyComment
}

}


private class StringsParser {

enum Error: Swift.Error {
case malformedFile(encountered: [String: LocalizedString])
case unexpectedToken
case missingToken
}

func parse(file url: URL) throws -> [String: LocalizedString] {
var strings = [String: LocalizedString]()
var fileContents = try String(contentsOf: url)
var comments = [String]()
while fileContents.count > 0 {
if fileContents.hasPrefix("/*") {
do {
try comments.append(parseComment(from: &fileContents))
} catch {
throw Error.malformedFile(encountered: strings)
}
} else if fileContents.hasPrefix("\"") {
do {
let localizedString = try parseLocalizedString(from: &fileContents, with: comments)
strings[localizedString.key] = localizedString
comments.removeAll()
} catch {
throw Error.malformedFile(encountered: strings)
}
} else if fileContents.hasWhitespacePrefix {
fileContents.removeFirst()
} else {
throw Error.malformedFile(encountered: strings)
}
}
return strings
}

private func parseComment(from fileContents: inout String) throws -> String {
var comment = ""
try expect(token: "/*", in: &fileContents)
while fileContents.occupiedWithoutPrefix("*/") {
comment.append(fileContents.removeFirst())
}
if fileContents.count == 0 { throw Error.missingToken }
fileContents = String(fileContents.dropFirst(2))
return comment
}

private func parseLocalizedString(from fileContents: inout String, with comments: [String]) throws -> LocalizedString {
var key = ""
var value = ""
try expect(token: "\"", in: &fileContents)
while fileContents.occupiedWithoutPrefix("\"") {
key.append(fileContents.removeFirst())
}
if fileContents.count == 0 { throw Error.missingToken }
fileContents.removeFirst()
try expect(token: "=", in: &fileContents)
try expect(token: "\"", in: &fileContents)
while fileContents.occupiedWithoutPrefix("\"") {
value.append(fileContents.removeFirst())
}
if fileContents.count == 0 { throw Error.missingToken }
fileContents.removeFirst()
try expect(token: ";", in: &fileContents)
return LocalizedString(comment: "/*\(comments.joined())*/", key: key, value: value)
}

private func expect(token: String, in string: inout String) throws {
while string.occupiedWithoutPrefix(token) {
if string.hasWhitespacePrefix { string.removeFirst() }
else { throw Error.unexpectedToken }
}
if string.count == 0 { throw Error.missingToken }
string = String(string.dropFirst(token.count))
}

}

}
4 changes: 1 addition & 3 deletions Sources/Hexiconjuror/Commands/Run/RunGeneration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,7 @@ final class RunGeneration: ConjurorCommand {
let generateDiff = GenerateDiff().with(environment)
let generateSource = GenerateSource().with(environment)
return generateDiff.run(options.options1)
.flatMap {
generateSource.run(options.options2.with(diff: $0))
}
.flatMap { generateSource.run(options.options2.with(diff: $0)) }
}

}
Expand Down
74 changes: 74 additions & 0 deletions Sources/Hexiconjuror/Extensions/CommandRegistry.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import Commandant
import Foundation

extension CommandRegistry {

func main(errorHandler: @escaping (ClientError) -> ()) -> Never {
let help = HelpCommand(registry: self)
register(help)
return main(defaultVerb: help.verb, errorHandler: errorHandler)
}

}

extension CommandRegistry {

struct Registration<E: EnvironmentObject> {

private let call: (CommandRegistry, E) -> ()

init(_ call: @escaping (CommandRegistry, E) -> ()) {
self.call = call
}

func callAsFunction(registry: CommandRegistry, environment: E) {
call(registry, environment)
}

}

@_functionBuilder struct CommandBuilder {

static func buildBlock<C1: ConjurorCommand, C2: ConjurorCommand>(_ c1: C1, _ c2: C2) -> [Registration<C1.Environment>] where C1.ClientError == ClientError, C2.ClientError == ClientError, C1.Environment == C2.Environment {
return [
Registration<C1.Environment> { (r: CommandRegistry<C1.ClientError>, env: C1.Environment) in r.register(c1.with(env)) },
Registration<C2.Environment> { (r: CommandRegistry<C2.ClientError>, env: C2.Environment) in r.register(c2.with(env)) },
]
}

static func buildBlock<C1: ConjurorCommand, C2: ConjurorCommand, C3: ConjurorCommand>(_ c1: C1, _ c2: C2, _ c3: C3) -> [Registration<C1.Environment>] where C1.ClientError == ClientError, C2.ClientError == ClientError, C3.ClientError == ClientError, C1.Environment == C2.Environment, C1.Environment == C3.Environment {
return [
Registration<C1.Environment> { (r: CommandRegistry<C1.ClientError>, env: C1.Environment) in r.register(c1.with(env)) },
Registration<C2.Environment> { (r: CommandRegistry<C2.ClientError>, env: C2.Environment) in r.register(c2.with(env)) },
Registration<C3.Environment> { (r: CommandRegistry<C3.ClientError>, env: C3.Environment) in r.register(c3.with(env)) },
]
}

static func buildBlock<C1: ConjurorCommand, C2: ConjurorCommand, C3: ConjurorCommand, C4: ConjurorCommand>(_ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4) -> [Registration<C1.Environment>] where C1.ClientError == ClientError, C2.ClientError == ClientError, C3.ClientError == ClientError, C4.ClientError == ClientError, C1.Environment == C2.Environment, C1.Environment == C3.Environment, C1.Environment == C4.Environment {
return [
Registration<C1.Environment> { (r: CommandRegistry<C1.ClientError>, env: C1.Environment) in r.register(c1.with(env)) },
Registration<C2.Environment> { (r: CommandRegistry<C2.ClientError>, env: C2.Environment) in r.register(c2.with(env)) },
Registration<C3.Environment> { (r: CommandRegistry<C3.ClientError>, env: C3.Environment) in r.register(c3.with(env)) },
Registration<C4.Environment> { (r: CommandRegistry<C4.ClientError>, env: C4.Environment) in r.register(c4.with(env)) },
]
}

static func buildBlock<C1: ConjurorCommand, C2: ConjurorCommand, C3: ConjurorCommand, C4: ConjurorCommand, C5: ConjurorCommand>(_ c1: C1, _ c2: C2, _ c3: C3, _ c4: C4, _ c5: C5) -> [Registration<C1.Environment>] where C1.ClientError == ClientError, C2.ClientError == ClientError, C3.ClientError == ClientError, C4.ClientError == ClientError, C5.ClientError == ClientError, C1.Environment == C2.Environment, C1.Environment == C3.Environment, C1.Environment == C4.Environment, C1.Environment == C5.Environment {
return [
Registration<C1.Environment> { (r: CommandRegistry<C1.ClientError>, env: C1.Environment) in r.register(c1.with(env)) },
Registration<C2.Environment> { (r: CommandRegistry<C2.ClientError>, env: C2.Environment) in r.register(c2.with(env)) },
Registration<C3.Environment> { (r: CommandRegistry<C3.ClientError>, env: C3.Environment) in r.register(c3.with(env)) },
Registration<C4.Environment> { (r: CommandRegistry<C4.ClientError>, env: C4.Environment) in r.register(c4.with(env)) },
Registration<C5.Environment> { (r: CommandRegistry<C5.ClientError>, env: C5.Environment) in r.register(c5.with(env)) },
]
}

}

func register<E: EnvironmentObject>(with environment: E, @CommandBuilder _ builder: () -> [Registration<E>]) -> CommandRegistry {
builder().forEach { $0(registry: self, environment: environment) }
register(HelpCommand(registry: self))
return self
}

}
7 changes: 7 additions & 0 deletions Sources/Hexiconjuror/Extensions/Mutate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import Foundation

func mutate<T>(_ value: T, mutator: (inout T) -> ()) -> T {
var copy = value
mutator(&copy)
return copy
}
11 changes: 11 additions & 0 deletions Sources/Hexiconjuror/Extensions/Result.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import Foundation

extension Result where Failure == Error {

func flatMapCatching<NewSuccess>(_ transform: (Success) throws -> (NewSuccess)) -> Result<NewSuccess, Failure> {
flatMap { success in
Result<NewSuccess, Failure> { try transform(success) }
}
}

}
9 changes: 9 additions & 0 deletions Sources/Hexiconjuror/Extensions/Sequence.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import Foundation

extension Sequence {

func sorted<Value: Comparable>(by keyPath: KeyPath<Element, Value>) -> [Element] {
sorted { $0[keyPath: keyPath] < $1[keyPath: keyPath] }
}

}
15 changes: 15 additions & 0 deletions Sources/Hexiconjuror/Extensions/String.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import Foundation

extension String {

var hasWhitespacePrefix: Bool {
first.flatMap { $0.unicodeScalars }
.flatMap { $0.count == 1 ? $0.first : nil }
.map(CharacterSet.whitespacesAndNewlines.contains) ?? false
}

func occupiedWithoutPrefix(_ prefix: String) -> Bool {
!hasPrefix(prefix) && count > 0
}

}
6 changes: 6 additions & 0 deletions Sources/Hexiconjuror/Extensions/Syntax DSL/FunctionBody.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ struct FunctionBody: SyntaxElement {

private var children: [SyntaxElement]

#if swift(<5.3)
init(_ child: () -> SyntaxElement) {
self.children = [child()]
}
#endif

init(@SyntaxBuilder children: () -> [SyntaxElement]) {
self.children = children()
}
Expand Down
Loading

0 comments on commit 7fba800

Please sign in to comment.