Skip to content

Commit

Permalink
Support for legacy .strings and .stringsdict file formats (#20)
Browse files Browse the repository at this point in the history
* Create StringSource enum with catalog and legacy cases for supporting loading strings from multiple sources

* Add tests to assert expected behaviour with Legacy.strings file

* Write initial parser for .strings file

* Integrate .strings parser

* Support decoding basic stringsdict entries

* Update Generate command to support multiple input files

* Add test for generating combined

* Update the plugin to support locating .strings and .stringsdict files

* Simplify plugin code

* Update PluginTests to embed .strings/.stringsdict in en.lproj and match to defaultLocalization. Nest in Resources directory

* Remove redundant isTruthy and extract merge method into extension

* Update generator to filter input files based on the developmentLanguage

* add debug logging

* Add tests for InputParser
  • Loading branch information
liamnichols committed Jun 9, 2024
1 parent 5296dbd commit 2158274
Show file tree
Hide file tree
Showing 30 changed files with 1,918 additions and 72 deletions.
13 changes: 11 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import PackageDescription

let package = Package(
name: "XCStringsTool",
defaultLocalization: "en",
platforms: [
.macOS(.v13)
],
Expand Down Expand Up @@ -33,6 +34,7 @@ let package = Package(
.target(name: "StringExtractor"),
.target(name: "StringGenerator"),
.target(name: "StringResource"),
.target(name: "StringSource"),
.target(name: "StringValidator"),
.target(name: "XCStringsToolConstants")
]
Expand Down Expand Up @@ -62,6 +64,13 @@ let package = Package(
name: "StringResource"
),

.target(
name: "StringSource",
dependencies: [
.target(name: "StringCatalog"),
]
),

.target(
name: "StringCatalog"
),
Expand All @@ -79,6 +88,7 @@ let package = Package(
dependencies: [
.target(name: "StringCatalog"),
.target(name: "StringResource"),
.target(name: "StringSource"),
.target(name: "SwiftIdentifier")
]
),
Expand Down Expand Up @@ -110,8 +120,7 @@ let package = Package(
.target(name: "XCStringsToolPlugin")
],
resources: [
.process("FeatureOne.xcstrings"),
.process("Localizable.xcstrings")
.process("Resources")
],
swiftSettings: [
.define("XCSTRINGS_TOOL_ACCESS_LEVEL_PUBLIC")
Expand Down
23 changes: 12 additions & 11 deletions Plugins/XCStringsToolPlugin/Command+XCStringsTool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,20 @@ extension XcodePluginContext: PluginContextProtocol {}
#endif

extension Command {
static func xcstringstool(for file: File, using context: PluginContextProtocol) throws -> Command {
static func xcstringstool(
forTableName tableName: String,
files: [File],
using context: PluginContextProtocol
) throws -> Command {
.buildCommand(
displayName: "XCStringsTool: Generate Swift code for ‘\(file.path.lastComponent)",
displayName: "XCStringsTool: Generate Swift code for ‘\(tableName)",
executable: try context.tool(named: "xcstrings-tool").path,
arguments: [
file.path,
context.outputPath(for: file)
],
inputFiles: [
file.path
arguments: files.map(\.path.string) + [
"--output", context.outputPath(for: tableName)
],
inputFiles: files.map(\.path),
outputFiles: [
context.outputPath(for: file)
context.outputPath(for: tableName)
]
)
}
Expand All @@ -37,8 +38,8 @@ private extension PluginContextProtocol {
pluginWorkDirectory.appending(subpath: "XCStringsTool")
}

func outputPath(for file: File) -> Path {
outputDirectory.appending("\(file.path.stem).swift")
func outputPath(for tableName: String) -> Path {
outputDirectory.appending("\(tableName).swift")
}
}

16 changes: 16 additions & 0 deletions Plugins/XCStringsToolPlugin/FileList+XCStringsTool.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import Foundation
import PackagePlugin

private let stringResourceExtensions: Set<String> = ["xcstrings", "strings", "stringsdict"]

extension FileList {
var stringResources: [File] {
self.filter { stringResourceExtensions.contains($0.path.extension ?? "") }
}

var stringTables: [(tableName: String, files: [File])] {
Dictionary(grouping: stringResources, by: \.path.stem)
.map { ($0.key, $0.value) }
.sorted(by: { $0.tableName.localizedStandardCompare($1.tableName) == .orderedAscending })
}
}
9 changes: 6 additions & 3 deletions Plugins/XCStringsToolPlugin/XCStringsToolPlugin+Xcode.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,12 @@ extension XCStringsToolPlugin: XcodeBuildToolPlugin {
context: XcodePluginContext,
target: XcodeTarget
) throws -> [Command] {
try target.inputFiles
.filter { $0.path.extension == "xcstrings" }
.map { try .xcstringstool(for: $0, using: context) }
try target
.inputFiles
.stringTables
.map { tableName, files in
try .xcstringstool(forTableName: tableName, files: files, using: context)
}
}
}
#endif
7 changes: 5 additions & 2 deletions Plugins/XCStringsToolPlugin/XCStringsToolPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ struct XCStringsToolPlugin: BuildToolPlugin {
}

return try sourceModule
.sourceFiles(withSuffix: "xcstrings")
.map { try .xcstringstool(for: $0, using: context) }
.sourceFiles
.stringTables
.map { tableName, files in
try .xcstringstool(forTableName: tableName, files: files, using: context)
}
}
}
119 changes: 119 additions & 0 deletions Sources/StringExtractor/Resource+LegacyFormat.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import Foundation
import StringCatalog
import StringResource
import SwiftIdentifier

extension Resource {
init(key: String, value: Any) throws {
let extracted = try Self.extract(from: value, key: key)
let identifier = SwiftIdentifier.variableIdentifier(for: key)

self.init(
key: key,
comment: nil, // not supported with the legacy format
identifier: identifier,
arguments: extracted.arguments,
sourceLocalization: extracted.sourceLocalization
)
}

static func extract(
from value: Any,
key: String
) throws -> (arguments: [Argument], sourceLocalization: String) {
switch value {
case let string as String:
try extract(from: string, key: key)
case let dictionary as [String: Any]:
try extract(from: dictionary, key: key)
default:
throw ExtractionError.localizationCorrupt(
ExtractionError.Context(
key: key,
debugDescription: """
Expected either a String or a plural dictionary ([String: Any]), \
but got ‘\(type(of: value))‘.
"""
)
)
}
}

static func extract(
from value: String,
key: String
) throws -> (arguments: [Argument], sourceLocalization: String) {
try extract(
from: StringParser.parse(value, expandingSubstitutions: [:]),
key: key
)
}

static func extract(
from values: [String: Any],
key: String
) throws -> (arguments: [Argument], sourceLocalization: String) {
var values = values

guard let value = values.removeValue(forKey: "NSStringLocalizedFormatKey") as? String else {
throw ExtractionError.localizationCorrupt(
ExtractionError.Context(
key: key,
debugDescription: "Legacy stringsdict entry is missing ‘NSStringLocalizedFormatKey‘."
)
)
}

var substitutions: [String: String] = [:]
var labels: [String] = []
for (name, value) in values {
guard var dict = value as? [String: String] else {
throw ExtractionError.localizationCorrupt(
ExtractionError.Context(
key: key,
debugDescription: "Nested object ‘\(name)‘ in ‘\(key)‘ is invalid type ‘\(type(of: value))‘."
)
)
}

guard let specType = dict.removeValue(forKey: "NSStringFormatSpecTypeKey") else {
throw ExtractionError.localizationCorrupt(
ExtractionError.Context(
key: key,
debugDescription: "Nested object ‘\(name)‘ in ‘\(key)‘ has not specified the ‘NSStringFormatSpecTypeKey‘ key."
)
)
}

guard specType == "NSStringPluralRuleType" else {
throw ExtractionError.localizationCorrupt(
ExtractionError.Context(
key: key,
debugDescription: "Nested object ‘\(name)‘ in ‘\(key)‘ is not a ‘NSStringPluralRuleType‘."
)
)
}

// TODO: Figure out if we actually need this ever?
let _ = dict.removeValue(forKey: "NSStringFormatValueTypeKey")

guard let value = dict["other"] ?? dict["one"] ?? dict.values.first else {
throw ExtractionError.localizationCorrupt(
ExtractionError.Context(
key: key,
debugDescription: "Plural substitution ‘\(name)‘ in ‘\(key)‘ does not define any variations."
)
)
}

substitutions[name] = value
labels.append(name)
}

// Parse the raw segments
let segments = StringParser.parse(value, expandingSubstitutions: substitutions)

// Convert the parsed arguments and labels into the correct data
return try extract(from: segments, labels: [:], key: key)
}
}
14 changes: 14 additions & 0 deletions Sources/StringExtractor/StringExtractor+LegacyFormat.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import StringResource
import typealias StringSource.LegacyFormat

extension StringExtractor {
public static func extractResources(
from legacyFormat: LegacyFormat
) throws -> Result {
try collect { resources, _ in
for (key, value) in legacyFormat {
resources.append(try Resource(key: key, value: value))
}
}
}
}
12 changes: 12 additions & 0 deletions Sources/StringExtractor/StringExtractor.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
import StringResource
import StringSource

public struct StringExtractor {
public typealias Result = (resources: [Resource], issues: [ExtractionIssue])

public static func extractResources(
from source: StringSource
) throws -> Result {
switch source {
case .catalog(let stringCatalog):
try extractResources(from: stringCatalog)
case .legacy(let legacyFormat):
try extractResources(from: legacyFormat)
}
}
}
39 changes: 39 additions & 0 deletions Sources/StringSource/StringSource.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import Foundation
import StringCatalog

/// A representation of a raw .strings or .stringsdict file
public typealias LegacyFormat = [String: Any]

/// Various sources of localized strings
public enum StringSource {
case catalog(StringCatalog)
case legacy(LegacyFormat)
}

// MARK: - Loading
extension StringSource {
public init(contentsOf fileURL: URL) throws {
switch fileURL.pathExtension {
case "xcstrings":
self = .catalog(try StringCatalog(contentsOf: fileURL))
case "stringsdict", "strings":
self = .legacy(try LegacyFormat(contentsOf: fileURL))
default:
throw StringSourceError.invalidFileFormat(fileURL.pathExtension)
}
}
}

// MARK: - Legacy Format
extension LegacyFormat {
init(contentsOf fileURL: URL) throws {
let data = try Data(contentsOf: fileURL)
let plist = try PropertyListSerialization.propertyList(from: data, format: nil)

if let dictionary = plist as? LegacyFormat {
self = dictionary
} else {
throw StringSourceError.invalidPropertyListContents
}
}
}
15 changes: 15 additions & 0 deletions Sources/StringSource/StringSourceError.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import Foundation

public enum StringSourceError: LocalizedError {
case invalidFileFormat(String)
case invalidPropertyListContents

public var errorDescription: String? {
switch self {
case .invalidFileFormat(let format):
"The file format ‘\(format)‘ is not supported."
case .invalidPropertyListContents:
"The root object of the property list was not a dictionary."
}
}
}
Loading

0 comments on commit 2158274

Please sign in to comment.