diff --git a/Sources/SWBApplePlatform/Plugin.swift b/Sources/SWBApplePlatform/Plugin.swift index 2288a849..2c3a6b05 100644 --- a/Sources/SWBApplePlatform/Plugin.swift +++ b/Sources/SWBApplePlatform/Plugin.swift @@ -202,7 +202,7 @@ struct XCStringsInputFileGroupingStrategyExtension: InputFileGroupingStrategyExt } func fileTypesCompilingToSwiftSources() -> [String] { - return [] + return ["text.json.xcstrings"] } } diff --git a/Sources/SWBApplePlatform/XCStringsCompiler.swift b/Sources/SWBApplePlatform/XCStringsCompiler.swift index 2a6eccd7..0b527947 100644 --- a/Sources/SWBApplePlatform/XCStringsCompiler.swift +++ b/Sources/SWBApplePlatform/XCStringsCompiler.swift @@ -11,6 +11,7 @@ //===----------------------------------------------------------------------===// import SWBUtil +import SWBMacro public import SWBCore import Foundation @@ -47,6 +48,147 @@ public final class XCStringsCompilerSpec: GenericCompilerSpec, SpecIdentifierTyp return } + if shouldGenerateSymbols(cbc) { + constructSymbolGenerationTask(cbc, delegate) + } + + if shouldCompileCatalog(cbc) { + await constructCatalogCompilationTask(cbc, delegate) + } + } + + public override var supportsInstallHeaders: Bool { + // Yes but we will only perform symbol generation in that case. + return true + } + + public override var supportsInstallAPI: Bool { + // Yes but we will only perform symbol generation in that case. + // This matches Asset Catalog symbol generation in order to workaround an issue with header whitespace. + // rdar://106447203 (Symbols: Enabling symbols for IB causes installapi failure) + return true + } + + /// Whether we should generate tasks to generate code symbols for strings. + private func shouldGenerateSymbols(_ cbc: CommandBuildContext) -> Bool { + guard cbc.scope.evaluate(BuiltinMacros.STRING_CATALOG_GENERATE_SYMBOLS) else { + return false + } + + // Yes for standard builds/installs as well as headers/api and exportloc (which includes headers). + // No for installloc. + let buildComponents = cbc.scope.evaluate(BuiltinMacros.BUILD_COMPONENTS) + guard buildComponents.contains("build") || buildComponents.contains("headers") || buildComponents.contains("api") else { + return false + } + + // Avoid symbol generation for xcstrings inside variant groups because that implies association with a resource such as a xib. + guard cbc.input.regionVariantName == nil else { + return false + } + + // We are only supporting Swift symbols at the moment so don't even generate the task if there are not Swift sources. + // If this is a synthesized Package resource target, we won't have Swift sources either. + // That's good since the symbol gen will happen for the code target instead. + let targetContainsSwiftSources = (cbc.producer.configuredTarget?.target as? StandardTarget)?.sourcesBuildPhase?.containsSwiftSources(cbc.producer, cbc.producer, cbc.scope, cbc.producer.filePathResolver) ?? false + guard targetContainsSwiftSources else { + return false + } + + return true + } + + /// Whether we should generate tasks to compile the .xcstrings file to .strings/dict files. + private func shouldCompileCatalog(_ cbc: CommandBuildContext) -> Bool { + // Yes for standard builds/installs and installloc. + // No for exportloc and headers/api. + let buildComponents = cbc.scope.evaluate(BuiltinMacros.BUILD_COMPONENTS) + guard buildComponents.contains("build") || buildComponents.contains("installLoc") else { + return false + } + + // If this is a Package target with a synthesized resource target, compile the catalog with the resources instead of here. + let isMainPackageWithResourceBundle = !cbc.scope.evaluate(BuiltinMacros.PACKAGE_RESOURCE_BUNDLE_NAME).isEmpty + return !isMainPackageWithResourceBundle + } + + private struct SymbolGenPayload: TaskPayload { + + let effectivePlatformName: String + + init(effectivePlatformName: String) { + self.effectivePlatformName = effectivePlatformName + } + + func serialize(to serializer: T) where T : SWBUtil.Serializer { + serializer.serializeAggregate(1) { + serializer.serialize(effectivePlatformName) + } + } + + init(from deserializer: any SWBUtil.Deserializer) throws { + try deserializer.beginAggregate(1) + self.effectivePlatformName = try deserializer.deserialize() + } + + } + + public override var payloadType: (any TaskPayload.Type)? { + return SymbolGenPayload.self + } + + /// Generates a task for generating code symbols for strings. + private func constructSymbolGenerationTask(_ cbc: CommandBuildContext, _ delegate: any TaskGenerationDelegate) { + // The template spec file contains fields suitable for the compilation step. + // But here we construct a custom command line for symbol generation. + let execPath = resolveExecutablePath(cbc, Path("xcstringstool")) + var commandLine = [execPath.str, "generate-symbols"] + + // For now shouldGenerateSymbols only returns true if there are Swift sources. + // So we only generate Swift symbols for now. + commandLine.append(contentsOf: ["--language", "swift"]) + + let outputDir = cbc.scope.evaluate(BuiltinMacros.DERIVED_SOURCES_DIR) + commandLine.append(contentsOf: ["--output-directory", outputDir.str]) + + // Input file + let inputPath = cbc.input.absolutePath + commandLine.append(inputPath.str) + + let outputPaths = [ + "GeneratedStringSymbols_\(inputPath.basenameWithoutSuffix).swift" + ] + .map { fileName in + return outputDir.join(fileName) + } + + for output in outputPaths { + delegate.declareOutput(FileToBuild(absolutePath: output, inferringTypeUsing: cbc.producer)) + } + + // Use just first path for now since we're not even sure if we'll support languages beyond Swift. + let ruleInfo = ["GenerateStringSymbols", outputPaths.first!.str, inputPath.str] + let execDescription = "Generate symbols for \(inputPath.basename)" + + let payload = SymbolGenPayload(effectivePlatformName: LocalizationBuildPortion.effectivePlatformName(scope: cbc.scope, sdkVariant: cbc.producer.sdkVariant)) + + delegate.createTask( + type: self, + payload: payload, + ruleInfo: ruleInfo, + commandLine: commandLine, + environment: environmentFromSpec(cbc, delegate), + workingDirectory: cbc.producer.defaultWorkingDirectory, + inputs: [inputPath], + outputs: outputPaths, + execDescription: execDescription, + preparesForIndexing: true, + enableSandboxing: enableSandboxing + ) + } + + /// Generates a task for compiling the .xcstrings to .strings/dict files. + private func constructCatalogCompilationTask(_ cbc: CommandBuildContext, _ delegate: any TaskGenerationDelegate) async { let commandLine = await commandLineFromTemplate(cbc, delegate, optionContext: discoveredCommandLineToolSpecInfo(cbc.producer, cbc.scope, delegate)).map(\.asString) // We can't know our precise outputs statically because we don't know what languages are in the xcstrings file, @@ -75,7 +217,17 @@ public final class XCStringsCompilerSpec: GenericCompilerSpec, SpecIdentifierTyp } if !outputs.isEmpty { - delegate.createTask(type: self, ruleInfo: defaultRuleInfo(cbc, delegate), commandLine: commandLine, environment: environmentFromSpec(cbc, delegate), workingDirectory: cbc.producer.defaultWorkingDirectory, inputs: [cbc.input.absolutePath], outputs: outputs, execDescription: resolveExecutionDescription(cbc, delegate), enableSandboxing: enableSandboxing) + delegate.createTask( + type: self, + ruleInfo: defaultRuleInfo(cbc, delegate), + commandLine: commandLine, + environment: environmentFromSpec(cbc, delegate), + workingDirectory: cbc.producer.defaultWorkingDirectory, + inputs: [cbc.input.absolutePath], + outputs: outputs, + execDescription: resolveExecutionDescription(cbc, delegate), + enableSandboxing: enableSandboxing + ) } else { // If there won't be any outputs, there's no reason to run the compiler. // However, we still need to leave some indication in the build graph that there was a compilable xcstrings file here so that generateLocalizationInfo can discover it. @@ -131,8 +283,7 @@ public final class XCStringsCompilerSpec: GenericCompilerSpec, SpecIdentifierTyp } public override func generateLocalizationInfo(for task: any ExecutableTask, input: TaskGenerateLocalizationInfoInput) -> [TaskGenerateLocalizationInfoOutput] { - // Tell the build system about the xcstrings file we took as input. - // No need to use a TaskPayload for this because the only data we need is input path, which is already stored on the Task. + // Tell the build system about the xcstrings file we took as input, as well as any generated symbol files. // These asserts just check to make sure the broader implementation hasn't changed since we wrote this method, // in case something here would need to change. @@ -142,7 +293,18 @@ public final class XCStringsCompilerSpec: GenericCompilerSpec, SpecIdentifierTyp // Our input paths are .xcstrings (only expecting 1). // NOTE: We also take same-named .strings/dict files as input, but those are only used to diagnose errors and when they exist we fail before we ever generate the task. - return [TaskGenerateLocalizationInfoOutput(compilableXCStringsPaths: task.inputPaths)] + var infos = [TaskGenerateLocalizationInfoOutput(compilableXCStringsPaths: task.inputPaths)] + + if let payload = task.payload as? SymbolGenPayload, + let xcstringsPath = task.inputPaths.only { + let generatedSourceFiles = task.outputPaths.filter { $0.fileExtension == "swift" } + var info = TaskGenerateLocalizationInfoOutput() + info.effectivePlatformName = payload.effectivePlatformName + info.generatedSymbolFilesByXCStringsPath = [xcstringsPath: generatedSourceFiles] + infos.append(info) + } + + return infos } } diff --git a/Sources/SWBBuildService/LocalizationInfo.swift b/Sources/SWBBuildService/LocalizationInfo.swift index 51e35e6c..974ef315 100644 --- a/Sources/SWBBuildService/LocalizationInfo.swift +++ b/Sources/SWBBuildService/LocalizationInfo.swift @@ -41,6 +41,15 @@ struct LocalizationInfoOutput { /// Paths to .stringsdata files produced by this target, grouped by build attributes such as platform and architecture. fileprivate(set) var producedStringsdataPaths: [LocalizationBuildPortion: Set] = [:] + + /// The name of the primary platform we were building for. + /// + /// Mac Catalyst is treated as its own platform. + fileprivate(set) var effectivePlatformName: String? + + /// Paths to generated source code files holding string symbols, keyed by xcstrings file path. + fileprivate(set) var generatedSymbolFilesByXCStringsPath = [Path: Set]() + } extension BuildDescriptionManager { @@ -98,9 +107,24 @@ extension BuildDescription { .reduce([:], { aggregate, partial in aggregate.merging(partial, uniquingKeysWith: +) }) .mapValues { Set($0) } + // Only really expecting to have one platform for a given build. + // So just use the first seen one as primary. + let effectivePlatformName = taskLocalizationOutputs.compactMap(\.effectivePlatformName).first + outputsByTarget[targetGUID, default: LocalizationInfoOutput(targetIdentifier: targetGUID)] .compilableXCStringsPaths.formUnion(taskXCStringsPaths) outputsByTarget[targetGUID]?.producedStringsdataPaths.merge(taskStringsdataPaths, uniquingKeysWith: { $0.union($1) }) + + if outputsByTarget[targetGUID]?.effectivePlatformName == nil && effectivePlatformName != nil { + outputsByTarget[targetGUID]?.effectivePlatformName = effectivePlatformName + } + + let taskGeneratedSymbolFiles = taskLocalizationOutputs + .map(\.generatedSymbolFilesByXCStringsPath) + .reduce([:], { aggregate, partial in aggregate.merging(partial, uniquingKeysWith: +) }) + .mapValues { Set($0) } + + outputsByTarget[targetGUID]?.generatedSymbolFilesByXCStringsPath.merge(taskGeneratedSymbolFiles, uniquingKeysWith: { $0.union($1) }) } return Array(outputsByTarget.values) diff --git a/Sources/SWBBuildService/Messages.swift b/Sources/SWBBuildService/Messages.swift index 2d5946c3..070b5d3c 100644 --- a/Sources/SWBBuildService/Messages.swift +++ b/Sources/SWBBuildService/Messages.swift @@ -901,7 +901,9 @@ private struct GetLocalizationInfoMsg: MessageHandler { for (buildPortion, paths) in infoOutput.producedStringsdataPaths { stringsdataPaths[LocalizationInfoBuildPortion(effectivePlatformName: buildPortion.effectivePlatformName, variant: buildPortion.variant, architecture: buildPortion.architecture)] = paths } - return LocalizationInfoMessagePayload(targetIdentifier: infoOutput.targetIdentifier, compilableXCStringsPaths: infoOutput.compilableXCStringsPaths, producedStringsdataPaths: stringsdataPaths) + var payload = LocalizationInfoMessagePayload(targetIdentifier: infoOutput.targetIdentifier, compilableXCStringsPaths: infoOutput.compilableXCStringsPaths, producedStringsdataPaths: stringsdataPaths, effectivePlatformName: infoOutput.effectivePlatformName) + payload.generatedSymbolFilesByXCStringsPath = infoOutput.generatedSymbolFilesByXCStringsPath + return payload })) return response } catch { diff --git a/Sources/SWBCore/Settings/BuiltinMacros.swift b/Sources/SWBCore/Settings/BuiltinMacros.swift index 1f67c0ea..f8483533 100644 --- a/Sources/SWBCore/Settings/BuiltinMacros.swift +++ b/Sources/SWBCore/Settings/BuiltinMacros.swift @@ -979,6 +979,7 @@ public final class BuiltinMacros { public static let SHALLOW_BUNDLE = BuiltinMacros.declareBooleanMacro("SHALLOW_BUNDLE") public static let SHARED_FRAMEWORKS_FOLDER_PATH = BuiltinMacros.declarePathMacro("SHARED_FRAMEWORKS_FOLDER_PATH") public static let SHARED_SUPPORT_FOLDER_PATH = BuiltinMacros.declarePathMacro("SHARED_SUPPORT_FOLDER_PATH") + public static let STRING_CATALOG_GENERATE_SYMBOLS = BuiltinMacros.declareBooleanMacro("STRING_CATALOG_GENERATE_SYMBOLS") public static let STRINGS_FILE_INPUT_ENCODING = BuiltinMacros.declareStringMacro("STRINGS_FILE_INPUT_ENCODING") public static let STRINGS_FILE_OUTPUT_ENCODING = BuiltinMacros.declareStringMacro("STRINGS_FILE_OUTPUT_ENCODING") public static let STRINGS_FILE_OUTPUT_FILENAME = BuiltinMacros.declareStringMacro("STRINGS_FILE_OUTPUT_FILENAME") @@ -2132,6 +2133,7 @@ public final class BuiltinMacros { SOURCE_ROOT, SPECIALIZATION_SDK_OPTIONS, SRCROOT, + STRING_CATALOG_GENERATE_SYMBOLS, STRINGSDATA_DIR, STRINGS_FILE_INPUT_ENCODING, STRINGS_FILE_OUTPUT_ENCODING, diff --git a/Sources/SWBCore/Settings/Settings.swift b/Sources/SWBCore/Settings/Settings.swift index 3bd6636e..b2c62b2b 100644 --- a/Sources/SWBCore/Settings/Settings.swift +++ b/Sources/SWBCore/Settings/Settings.swift @@ -4157,6 +4157,7 @@ private class SettingsBuilder { if let project, project.isPackage, project.developmentRegion != nil { table.push(BuiltinMacros.LOCALIZATION_EXPORT_SUPPORTED, literal: true) table.push(BuiltinMacros.SWIFT_EMIT_LOC_STRINGS, literal: true) + table.push(BuiltinMacros.STRING_CATALOG_GENERATE_SYMBOLS, literal: true) } return table diff --git a/Sources/SWBCore/TaskGeneration.swift b/Sources/SWBCore/TaskGeneration.swift index 511db51e..6d267632 100644 --- a/Sources/SWBCore/TaskGeneration.swift +++ b/Sources/SWBCore/TaskGeneration.swift @@ -1163,12 +1163,21 @@ public struct TaskGenerateLocalizationInfoOutput { /// Paths to .stringsdata files produced by this task, grouped by build attributes such as platform and architecture. public let producedStringsdataPaths: [LocalizationBuildPortion: [Path]] + /// The name of the primary platform we were building for. + /// + /// Mac Catalyst is treated as its own platform. + public var effectivePlatformName: String? + + /// Paths to generated source code files holding string symbols, keyed by xcstrings file path. + public var generatedSymbolFilesByXCStringsPath = [Path: [Path]]() + /// Create output to describe some portion of localization info for a Task. /// /// - Parameters: /// - compilableXCStringsPaths: Paths to input source .xcstrings files. /// - producedStringsdataPaths: Paths to output .stringsdata files. - public init(compilableXCStringsPaths: [Path] = [], producedStringsdataPaths: [LocalizationBuildPortion: [Path]] = [:]) { + public init(compilableXCStringsPaths: [Path] = [], + producedStringsdataPaths: [LocalizationBuildPortion: [Path]] = [:]) { self.compilableXCStringsPaths = compilableXCStringsPaths self.producedStringsdataPaths = producedStringsdataPaths } diff --git a/Sources/SWBProjectModel/PIFGenerationModel.swift b/Sources/SWBProjectModel/PIFGenerationModel.swift index 698a7343..ce12efbf 100644 --- a/Sources/SWBProjectModel/PIFGenerationModel.swift +++ b/Sources/SWBProjectModel/PIFGenerationModel.swift @@ -1128,6 +1128,11 @@ public struct SwiftBuildFileType: Sendable { fileTypeIdentifier: "folder.abstractassetcatalog" ) + public static let xcstrings: SwiftBuildFileType = SwiftBuildFileType( + fileType: "xcstrings", + fileTypeIdentifier: "text.json.xcstrings" + ) + public static let xcdatamodeld: SwiftBuildFileType = SwiftBuildFileType( fileType: "xcdatamodeld", fileTypeIdentifier: "wrapper.xcdatamodeld" @@ -1165,6 +1170,7 @@ public struct SwiftBuildFileType: Sendable { public static let all: [SwiftBuildFileType] = [ .xcassets, + .xcstrings, .xcdatamodeld, .xcdatamodel, .xcmappingmodel, diff --git a/Sources/SWBProtocol/MessageSupport.swift b/Sources/SWBProtocol/MessageSupport.swift index 19ccc2bf..2333b632 100644 --- a/Sources/SWBProtocol/MessageSupport.swift +++ b/Sources/SWBProtocol/MessageSupport.swift @@ -542,9 +542,21 @@ public struct LocalizationInfoMessagePayload: SerializableCodable, Equatable, Se /// Paths to .stringsdata files produced by this target, grouped by build attributes such as platform and architecture. public let producedStringsdataPaths: [LocalizationInfoBuildPortion: Set] - public init(targetIdentifier: String, compilableXCStringsPaths: Set, producedStringsdataPaths: [LocalizationInfoBuildPortion: Set]) { + /// The name of the primary platform we were building for. + /// + /// Mac Catalyst is treated as its own platform. + public let effectivePlatformName: String? + + /// Paths to generated source code files holding string symbols, keyed by xcstrings file path. + public var generatedSymbolFilesByXCStringsPath = [Path: Set]() + + public init(targetIdentifier: String, + compilableXCStringsPaths: Set, + producedStringsdataPaths: [LocalizationInfoBuildPortion: Set], + effectivePlatformName: String?) { self.targetIdentifier = targetIdentifier self.compilableXCStringsPaths = compilableXCStringsPaths self.producedStringsdataPaths = producedStringsdataPaths + self.effectivePlatformName = effectivePlatformName } } diff --git a/Sources/SWBTaskConstruction/TaskProducers/BuildPhaseTaskProducers/FilesBasedBuildPhaseTaskProducer.swift b/Sources/SWBTaskConstruction/TaskProducers/BuildPhaseTaskProducers/FilesBasedBuildPhaseTaskProducer.swift index 710ad65d..f80e2178 100644 --- a/Sources/SWBTaskConstruction/TaskProducers/BuildPhaseTaskProducers/FilesBasedBuildPhaseTaskProducer.swift +++ b/Sources/SWBTaskConstruction/TaskProducers/BuildPhaseTaskProducers/FilesBasedBuildPhaseTaskProducer.swift @@ -376,6 +376,14 @@ class FilesBasedBuildPhaseTaskProducerBase: PhasedTaskProducer { let buildPhaseFileWarningContext = BuildPhaseFileWarningContext(context, scope) + // Sadly we need to make various decisions based on codegen of Asset and String Catalogs. + // We can remove this when we get rid of build phases. + let sourceFileCount = (self.targetContext.configuredTarget?.target as? SWBCore.StandardTarget)?.sourcesBuildPhase?.buildFiles.count ?? 0 + let stringsFileTypes = ["text.plist.strings", "text.plist.stringsdict"].map { context.lookupFileType(identifier: $0)! } + var xcstringsBases = Set() + let shouldCodeGenAssets = scope.evaluate(BuiltinMacros.ASSETCATALOG_COMPILER_GENERATE_ASSET_SYMBOLS) && sourceFileCount > 0 + let shouldCodeGenStrings = scope.evaluate(BuiltinMacros.STRING_CATALOG_GENERATE_SYMBOLS) && sourceFileCount > 0 + // Helper function for adding a resolved item. The build file can be nil here if the client wants to add a file divorced from any build file (e.g., because the build file contains context which shouldn't be applied to this file). func addResolvedItem(buildFile: SWBCore.BuildFile?, path: Path, reference: SWBCore.Reference?, fileType: FileTypeSpec, shouldUsePrefixHeader: Bool = true) { let base = path.basenameWithoutSuffix.lowercased() @@ -405,13 +413,20 @@ class FilesBasedBuildPhaseTaskProducerBase: PhasedTaskProducer { do { let (reference, path, fileType) = try context.resolveBuildFileReference(buildFile) - let sourceFiles = (self.targetContext.configuredTarget?.target as? SWBCore.StandardTarget)?.sourcesBuildPhase?.buildFiles.count ?? 0 - if scope.evaluate(BuiltinMacros.ASSETCATALOG_COMPILER_GENERATE_ASSET_SYMBOLS) && (sourceFiles > 0) { + if shouldCodeGenAssets { // Ignore xcassets in Resource Copy Phase since they're now added to the Compile Sources phase for codegen. if producer.buildPhase is SWBCore.ResourcesBuildPhase && fileType.conformsTo(identifier: "folder.abstractassetcatalog") { continue } } + if shouldCodeGenStrings { + // Ignore xcstrings in Resource Copy Phase since they're now added to the Compile Sources phase for codegen. + if producer.buildPhase is SWBCore.ResourcesBuildPhase && fileType.conformsTo(identifier: "text.json.xcstrings") { + // Keep the basename because later we need to ignore same-named .strings/dict files as well. + xcstringsBases.insert(path.basenameWithoutSuffix) + continue + } + } // Compilation of .rkassets depends on additional auxiliary inputs that are not // accessible from a spec class. Instead, they are handled entirely by their own @@ -630,6 +645,14 @@ class FilesBasedBuildPhaseTaskProducerBase: PhasedTaskProducer { continue } + // Ignore certain .strings/dict files in Resources phase when codegen for xcstrings is enabled. + if shouldCodeGenStrings && + producer.buildPhase is SWBCore.ResourcesBuildPhase && + fileType.conformsToAny(stringsFileTypes) && + xcstringsBases.contains(path.basenameWithoutSuffix) { + continue + } + // Have the build files context add the file to the appropriate file group. buildFilesContext.addFile(fileToBuild, context, scope) } diff --git a/Sources/SWBTaskConstruction/TaskProducers/BuildPhaseTaskProducers/SourcesTaskProducer.swift b/Sources/SWBTaskConstruction/TaskProducers/BuildPhaseTaskProducers/SourcesTaskProducer.swift index b2ab7c3e..a225c3b0 100644 --- a/Sources/SWBTaskConstruction/TaskProducers/BuildPhaseTaskProducers/SourcesTaskProducer.swift +++ b/Sources/SWBTaskConstruction/TaskProducers/BuildPhaseTaskProducers/SourcesTaskProducer.swift @@ -250,16 +250,50 @@ final class SourcesTaskProducer: FilesBasedBuildPhaseTaskProducerBase, FilesBase } override func additionalBuildFiles(_ scope: MacroEvaluationScope) -> [BuildFile] { + var additionalBuildFiles = [BuildFile]() + + // Both Asset Catalogs and String Catalogs need moved to the Sources phase to enable codegen. + // This isn't great but can be removed once we eliminate build phases. + let standardTarget = targetContext.configuredTarget?.target as? StandardTarget let sourceFiles = standardTarget?.sourcesBuildPhase?.buildFiles.count ?? 0 + if scope.evaluate(BuiltinMacros.ASSETCATALOG_COMPILER_GENERATE_ASSET_SYMBOLS) && (sourceFiles > 0) { // Add xcassets to Compile Sources phase to enable codegen. - return standardTarget?.resourcesBuildPhase?.buildFiles.filter { buildFile in + let catalogs = standardTarget?.resourcesBuildPhase?.buildFiles.filter { buildFile in isAssetCatalog(scope: scope, buildFile: buildFile, context: targetContext, includeGenerated: true) } ?? [] - } else { - return [] + additionalBuildFiles.append(contentsOf: catalogs) + } + + if scope.evaluate(BuiltinMacros.STRING_CATALOG_GENERATE_SYMBOLS) && (sourceFiles > 0) { + let allResources = standardTarget?.resourcesBuildPhase?.buildFiles ?? [] + var stringCatalogs = [BuildFile]() + var stringTableNames = Set() + var extraFiles = [BuildFile]() + + // Add xcstrings to Compile Sources phase to enable codegen. + for buildFile in allResources { + if let fileRef = try? targetContext.resolveBuildFileReference(buildFile), fileRef.fileType.conformsTo(identifier: "text.json.xcstrings") { + stringTableNames.insert(fileRef.absolutePath.basenameWithoutSuffix) + stringCatalogs.append(buildFile) + } + } + + // The xcstrings file grouping strategy also subsumes same-named .strings and .stringsdict files. + let stringsFileTypes = ["text.plist.strings", "text.plist.stringsdict"].map { context.lookupFileType(identifier: $0)! } + for buildFile in allResources { + if let fileRef = try? targetContext.resolveBuildFileReference(buildFile), + fileRef.fileType.conformsToAny(stringsFileTypes), + stringTableNames.contains(fileRef.absolutePath.basenameWithoutSuffix) { + extraFiles.append(buildFile) + } + } + + additionalBuildFiles.append(contentsOf: stringCatalogs + extraFiles) } + + return additionalBuildFiles } override func additionalFilesToBuild(_ scope: MacroEvaluationScope) -> [FileToBuild] { @@ -1541,7 +1575,7 @@ final class SourcesTaskProducer: FilesBasedBuildPhaseTaskProducerBase, FilesBase if isForInstallLoc { // For installLoc, we really only care about valid localized content from the sources task producer - tasks = tasks.filter { $0.inputs.contains(where: { $0.path.isValidLocalizedContent(scope) }) } + tasks = tasks.filter { $0.inputs.contains(where: { $0.path.isValidLocalizedContent(scope) || $0.path.fileExtension == "xcstrings" }) } } return tasks @@ -1593,11 +1627,11 @@ final class SourcesTaskProducer: FilesBasedBuildPhaseTaskProducerBase, FilesBase /// Custom override to support supplying the resources directory when constructing tasks. override func constructTasksForRule(_ rule: any BuildRuleAction, _ group: FileToBuildGroup, _ buildFilesContext: BuildFilesProcessingContext, _ scope: MacroEvaluationScope, _ delegate: any TaskGenerationDelegate) async { - // Ignore asset catalog tasks during installLoc. - // Asset catalogs are moved to the sources phase for generated asset symbols. + // For installloc, let's ignore anything not in an .lproj. + // An exception is xcstrings files, which can be in the Sources phase for symbol generation. if scope.evaluate(BuiltinMacros.BUILD_COMPONENTS).contains("installLoc") { - let isAssetCatalog = group.files.contains(where: { $0.fileType.conformsTo(identifier: "folder.abstractassetcatalog") }) - guard !isAssetCatalog else { return } + let isXCStrings = group.files.contains(where: { $0.fileType.conformsTo(identifier: "text.json.xcstrings") }) + guard isXCStrings || group.isValidLocalizedContent(scope) else { return } } // Compute the resources directory. diff --git a/Sources/SwiftBuild/SWBBuildServiceSession.swift b/Sources/SwiftBuild/SWBBuildServiceSession.swift index f6d89617..07b29c65 100644 --- a/Sources/SwiftBuild/SWBBuildServiceSession.swift +++ b/Sources/SwiftBuild/SWBBuildServiceSession.swift @@ -279,11 +279,21 @@ public final class SWBBuildServiceSession: Sendable { var targetInfos = [String: SWBLocalizationTargetInfo]() for payload in msg.targetInfos { let xcstrings = payload.compilableXCStringsPaths.map { $0.normalize().str } + var stringsdata = [SWBLocalizationBuildPortion: Set]() for (buildPortion, paths) in payload.producedStringsdataPaths { stringsdata[SWBLocalizationBuildPortion(effectivePlatformName: buildPortion.effectivePlatformName, variant: buildPortion.variant, architecture: buildPortion.architecture)] = Set(paths.map({ $0.normalize().str })) } - targetInfos[payload.targetIdentifier] = SWBLocalizationTargetInfo(compilableXCStringsPaths: Set(xcstrings), stringsdataPathsByBuildPortion: stringsdata) + + var swbTargetInfo = SWBLocalizationTargetInfo(compilableXCStringsPaths: Set(xcstrings), stringsdataPathsByBuildPortion: stringsdata, effectivePlatformName: payload.effectivePlatformName) + + var generatedFiles = [String: Set]() + for (xcstringsPath, generatedPaths) in payload.generatedSymbolFilesByXCStringsPath { + generatedFiles[xcstringsPath.normalize().str] = Set(generatedPaths.map({ $0.normalize().str })) + } + swbTargetInfo.generatedSymbolFilesByXCStringsPath = generatedFiles + + targetInfos[payload.targetIdentifier] = swbTargetInfo } return SWBLocalizationInfo(infoByTarget: targetInfos) diff --git a/Sources/SwiftBuild/SWBLocalizationSupport.swift b/Sources/SwiftBuild/SWBLocalizationSupport.swift index 5450dd7a..ad48c42a 100644 --- a/Sources/SwiftBuild/SWBLocalizationSupport.swift +++ b/Sources/SwiftBuild/SWBLocalizationSupport.swift @@ -33,6 +33,15 @@ public struct SWBLocalizationTargetInfo: Sendable { public var producedStringsdataPaths: Set { return stringsdataPathsByBuildPortion.values.reduce([]) { $0.union($1) } } + + /// The name of the primary platform we were building for. + /// + /// Mac Catalyst is treated as its own platform. + public let effectivePlatformName: String? + + /// Paths to generated source code files holding string symbols, keyed by xcstrings file path. + public internal(set) var generatedSymbolFilesByXCStringsPath = [String: Set]() + } /// Describes attributes of a portion of a build, for example platform and architecture, that are relevant to distinguishing localized strings extracted during a build. diff --git a/Tests/SWBTaskConstructionTests/XCStringsSymbolGenTests.swift b/Tests/SWBTaskConstructionTests/XCStringsSymbolGenTests.swift new file mode 100644 index 00000000..a11cc955 --- /dev/null +++ b/Tests/SWBTaskConstructionTests/XCStringsSymbolGenTests.swift @@ -0,0 +1,1073 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import struct Foundation.Data +import struct Foundation.UUID + +import Testing + +import SWBUtil +import enum SWBProtocol.ExternalToolResult +import SWBCore +import SWBTaskConstruction +import SWBTestSupport + +@Suite +fileprivate struct XCStringsSymbolGenTests: CoreBasedTests { + + @Test(.requireSDKs(.macOS)) + func symbolGenerationPlusCompile() async throws { + let testProject = try await TestProject( + "Project", + groupTree: TestGroup( + "ProjectSources", + path: "Sources", + children: [ + TestFile("MyFramework.swift"), + TestFile("Localizable.xcstrings"), + ] + ), + buildConfigurations: [ + TestBuildConfiguration("Debug", buildSettings: [ + "PRODUCT_NAME": "$(TARGET_NAME)", + ]) + ], + targets: [ + TestStandardTarget( + "MyFramework", + type: .framework, + buildConfigurations: [ + TestBuildConfiguration("Debug", buildSettings: [ + "SKIP_INSTALL": "YES", + "SWIFT_EXEC": swiftCompilerPath.str, + "SWIFT_VERSION": "5.5", + "GENERATE_INFOPLIST_FILE": "YES", + "STRING_CATALOG_GENERATE_SYMBOLS": "YES" // This is what we're primarily testing + ]), + ], + buildPhases: [ + TestSourcesBuildPhase([ + "MyFramework.swift" + ]), + TestResourcesBuildPhase([ + "Localizable.xcstrings" + ]) + ] + ) + ], + developmentRegion: "en" + ) + + // Mock xcstringstool since it will be called for --dry-run. + // Pretend our xcstrings file contains English and German strings, and that they have variations. + let xcstringsTool = MockXCStringsTool(relativeOutputFilePaths: [ "/tmp/Test/Project/Sources/Localizable.xcstrings" : [ // input + "en.lproj/Localizable.strings", + "en.lproj/Localizable.stringsdict", + "de.lproj/Localizable.strings", + "de.lproj/Localizable.stringsdict", + ]], requiredCommandLine: [ + "xcstringstool", "compile", + "--dry-run", + "--output-directory", "/tmp/Test/Project/build/Project.build/Debug/MyFramework.build", + "/tmp/Test/Project/Sources/Localizable.xcstrings" // input file + ]) + + let tester = try await TaskConstructionTester(getCore(), testProject) + let swiftFeatures = try await self.swiftFeatures + + await tester.checkBuild(runDestination: .macOS, clientDelegate: xcstringsTool) { results in + results.checkNoDiagnostics() + + results.checkTarget("MyFramework") { target in + // There should not be any generic CpResource tasks because that would indicate that the xcstrings file is just being copied as is. + results.checkNoTask(.matchTarget(target), .matchRuleType("CpResource")) + + // First there should be symbol gen. + results.checkTask(.matchTarget(target), .matchRule(["GenerateStringSymbols", "/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/DerivedSources/GeneratedStringSymbols_Localizable.swift", "/tmp/Test/Project/Sources/Localizable.xcstrings"])) { task in + + // Input is source xcstrings file. + task.checkInputs(contain: [.path("/tmp/Test/Project/Sources/Localizable.xcstrings")]) + + // Output is .swift file. + task.checkOutputs([ + .path("/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/DerivedSources/GeneratedStringSymbols_Localizable.swift"), + ]) + + task.checkCommandLine([ + "xcstringstool", "generate-symbols", + "--language", "swift", + "--output-directory", "/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/DerivedSources", + "/tmp/Test/Project/Sources/Localizable.xcstrings" // input file + ]) + } + + // The output of that should be compiled by Swift. + let targetArchitecture = results.runDestinationTargetArchitecture + if swiftFeatures.has(.emitLocalizedStrings) { + results.checkTask(.matchTarget(target), .matchRule(["SwiftDriver Compilation", "MyFramework", "normal", targetArchitecture, "com.apple.xcode.tools.swift.compiler"])) { task in + task.checkInputs(contain: [.path("/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/DerivedSources/GeneratedStringSymbols_Localizable.swift")]) + } + } else { + results.checkTask(.matchTarget(target), .matchRule(["CompileSwiftSources", "normal", targetArchitecture, "com.apple.xcode.tools.swift.compiler"])) { task in + task.checkInputs(contain: [.path("/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/DerivedSources/GeneratedStringSymbols_Localizable.swift")]) + } + } + + // We need a task to compile the XCStrings into .strings and .stringsdict files. + results.checkTask(.matchTarget(target), .matchRule(["CompileXCStrings", "/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/", "/tmp/Test/Project/Sources/Localizable.xcstrings"])) { task in + + // Input is source xcstrings file. + task.checkInputs(contain: [.path("/tmp/Test/Project/Sources/Localizable.xcstrings")]) + + // Outputs are .strings and .stringsdicts in the TempResourcesDir. + task.checkOutputs([ + .path("/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/en.lproj/Localizable.strings"), + .path("/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/en.lproj/Localizable.stringsdict"), + .path("/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/de.lproj/Localizable.strings"), + .path("/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/de.lproj/Localizable.stringsdict"), + ]) + + task.checkCommandLine([ + "xcstringstool", "compile", + "--output-directory", "/tmp/Test/Project/build/Project.build/Debug/MyFramework.build", + "/tmp/Test/Project/Sources/Localizable.xcstrings" // input file + ]) + } + + + // Then we need the standard CopyStringsFile tasks to have the compiled .strings/dict as input. + results.checkTask(.matchTarget(target), .matchRule(["CopyStringsFile", "/tmp/Test/Project/build/Debug/MyFramework.framework/Versions/A/Resources/en.lproj/Localizable.strings", "/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/en.lproj/Localizable.strings"])) { _ in } + results.checkTask(.matchTarget(target), .matchRule(["CopyStringsFile", "/tmp/Test/Project/build/Debug/MyFramework.framework/Versions/A/Resources/en.lproj/Localizable.stringsdict", "/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/en.lproj/Localizable.stringsdict"])) { _ in } + results.checkTask(.matchTarget(target), .matchRule(["CopyStringsFile", "/tmp/Test/Project/build/Debug/MyFramework.framework/Versions/A/Resources/de.lproj/Localizable.strings", "/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/de.lproj/Localizable.strings"])) { _ in } + results.checkTask(.matchTarget(target), .matchRule(["CopyStringsFile", "/tmp/Test/Project/build/Debug/MyFramework.framework/Versions/A/Resources/de.lproj/Localizable.stringsdict", "/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/de.lproj/Localizable.stringsdict"])) { _ in } + + // And these should be the only CopyStringsFile tasks. + results.checkNoTask(.matchTarget(target), .matchRuleType("CopyStringsFile")) + } + } + } + + @Test(.requireSDKs(.macOS)) + func multipleXCStringsSymbolGenerationPlusCompile() async throws { + let testProject = try await TestProject( + "Project", + groupTree: TestGroup( + "ProjectSources", + path: "Sources", + children: [ + TestFile("MyFramework.swift"), + TestFile("Localizable.xcstrings"), + TestFile("CustomTable.xcstrings"), + TestFile("Table with spaces.xcstrings"), + ] + ), + buildConfigurations: [ + TestBuildConfiguration("Debug", buildSettings: [ + "PRODUCT_NAME": "$(TARGET_NAME)", + ]) + ], + targets: [ + TestStandardTarget( + "MyFramework", + type: .framework, + buildConfigurations: [ + TestBuildConfiguration("Debug", buildSettings: [ + "SKIP_INSTALL": "YES", + "SWIFT_EXEC": swiftCompilerPath.str, + "SWIFT_VERSION": "5.5", + "GENERATE_INFOPLIST_FILE": "YES", + "STRING_CATALOG_GENERATE_SYMBOLS": "YES", + ]), + ], + buildPhases: [ + TestSourcesBuildPhase([ + "MyFramework.swift" + ]), + TestResourcesBuildPhase([ + "Localizable.xcstrings", + "CustomTable.xcstrings", + "Table with spaces.xcstrings", + ]) + ] + ) + ], + developmentRegion: "en" + ) + + // Pretend our xcstrings files contain English and German strings, without variation. + let xcstringsTool = MockXCStringsTool(relativeOutputFilePaths: [ + "/tmp/Test/Project/Sources/Localizable.xcstrings" : [ + "en.lproj/Localizable.strings", + "de.lproj/Localizable.strings", + ], + "/tmp/Test/Project/Sources/CustomTable.xcstrings" : [ + "en.lproj/CustomTable.strings", + "de.lproj/CustomTable.strings", + ], + "/tmp/Test/Project/Sources/Table with spaces.xcstrings" : [ + "en.lproj/Table with spaces.strings", + "de.lproj/Table with spaces.strings", + ], + ], requiredCommandLine: nil) + + let tester = try await TaskConstructionTester(getCore(), testProject) + let swiftFeatures = try await self.swiftFeatures + + await tester.checkBuild(runDestination: .macOS, clientDelegate: xcstringsTool) { results in + results.checkNoDiagnostics() + + results.checkTarget("MyFramework") { target in + // We should have two separate GenerateStringSymbols tasks. + results.checkTask(.matchTarget(target), .matchRule(["GenerateStringSymbols", "/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/DerivedSources/GeneratedStringSymbols_Localizable.swift", "/tmp/Test/Project/Sources/Localizable.xcstrings"])) { _ in } + results.checkTask(.matchTarget(target), .matchRule(["GenerateStringSymbols", "/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/DerivedSources/GeneratedStringSymbols_CustomTable.swift", "/tmp/Test/Project/Sources/CustomTable.xcstrings"])) { _ in } + results.checkTask(.matchTarget(target), .matchRule(["GenerateStringSymbols", "/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/DerivedSources/GeneratedStringSymbols_Table with spaces.swift", "/tmp/Test/Project/Sources/Table with spaces.xcstrings"])) { _ in } + + // Both of those output files should be consumed by the Swift Driver. + let targetArchitecture = results.runDestinationTargetArchitecture + if swiftFeatures.has(.emitLocalizedStrings) { + results.checkTask(.matchTarget(target), .matchRule(["SwiftDriver Compilation", "MyFramework", "normal", targetArchitecture, "com.apple.xcode.tools.swift.compiler"])) { task in + task.checkInputs(contain: [ + .path("/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/DerivedSources/GeneratedStringSymbols_Localizable.swift"), + .path("/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/DerivedSources/GeneratedStringSymbols_CustomTable.swift"), + .path("/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/DerivedSources/GeneratedStringSymbols_Table with spaces.swift"), + ]) + } + } else { + results.checkTask(.matchTarget(target), .matchRule(["CompileSwiftSources", "normal", targetArchitecture, "com.apple.xcode.tools.swift.compiler"])) { task in + task.checkInputs(contain: [ + .path("/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/DerivedSources/GeneratedStringSymbols_Localizable.swift"), + .path("/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/DerivedSources/GeneratedStringSymbols_CustomTable.swift"), + .path("/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/DerivedSources/GeneratedStringSymbols_Table with spaces.swift"), + ]) + } + } + + // We should have two separate CompileXCStrings tasks. + results.checkTask(.matchTarget(target), .matchRule(["CompileXCStrings", "/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/", "/tmp/Test/Project/Sources/Localizable.xcstrings"])) { _ in } + results.checkTask(.matchTarget(target), .matchRule(["CompileXCStrings", "/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/", "/tmp/Test/Project/Sources/CustomTable.xcstrings"])) { _ in } + results.checkTask(.matchTarget(target), .matchRule(["CompileXCStrings", "/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/", "/tmp/Test/Project/Sources/Table with spaces.xcstrings"])) { _ in } + + // We should then have 4 CopyStringsFile tasks consuming those outputs. + results.checkTask(.matchTarget(target), .matchRule(["CopyStringsFile", "/tmp/Test/Project/build/Debug/MyFramework.framework/Versions/A/Resources/en.lproj/Localizable.strings", "/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/en.lproj/Localizable.strings"])) { _ in } + results.checkTask(.matchTarget(target), .matchRule(["CopyStringsFile", "/tmp/Test/Project/build/Debug/MyFramework.framework/Versions/A/Resources/en.lproj/CustomTable.strings", "/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/en.lproj/CustomTable.strings"])) { _ in } + results.checkTask(.matchTarget(target), .matchRule(["CopyStringsFile", "/tmp/Test/Project/build/Debug/MyFramework.framework/Versions/A/Resources/en.lproj/Table with spaces.strings", "/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/en.lproj/Table with spaces.strings"])) { _ in } + results.checkTask(.matchTarget(target), .matchRule(["CopyStringsFile", "/tmp/Test/Project/build/Debug/MyFramework.framework/Versions/A/Resources/de.lproj/Localizable.strings", "/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/de.lproj/Localizable.strings"])) { _ in } + results.checkTask(.matchTarget(target), .matchRule(["CopyStringsFile", "/tmp/Test/Project/build/Debug/MyFramework.framework/Versions/A/Resources/de.lproj/CustomTable.strings", "/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/de.lproj/CustomTable.strings"])) { _ in } + results.checkTask(.matchTarget(target), .matchRule(["CopyStringsFile", "/tmp/Test/Project/build/Debug/MyFramework.framework/Versions/A/Resources/de.lproj/Table with spaces.strings", "/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/de.lproj/Table with spaces.strings"])) { _ in } + results.checkNoTask(.matchTarget(target), .matchRuleType("CopyStringsFile")) + } + } + } + + // An xcstrings file in Copy Files rather than a Resources build phase should just be copied. + // (Assuming APPLY_RULES_IN_COPY_FILES has not been set.) + @Test(.requireSDKs(.macOS)) + func inCopyFilesStillNoSymbolGeneration() async throws { + let testProject = try await TestProject( + "Project", + groupTree: TestGroup( + "ProjectSources", + path: "Sources", + children: [ + TestFile("MyFramework.swift"), + TestFile("Localizable.xcstrings"), + ] + ), + buildConfigurations: [ + TestBuildConfiguration("Debug", buildSettings: [ + "PRODUCT_NAME": "$(TARGET_NAME)", + ]) + ], + targets: [ + TestStandardTarget( + "MyFramework", + type: .framework, + buildConfigurations: [ + TestBuildConfiguration("Debug", buildSettings: [ + "SKIP_INSTALL": "YES", + "SWIFT_EXEC": swiftCompilerPath.str, + "SWIFT_VERSION": "5.5", + "GENERATE_INFOPLIST_FILE": "YES", + "STRING_CATALOG_GENERATE_SYMBOLS": "YES", + ]), + ], + buildPhases: [ + TestSourcesBuildPhase([ + "MyFramework.swift" + ]), + TestCopyFilesBuildPhase([ + "Localizable.xcstrings", + ], destinationSubfolder: .resources, onlyForDeployment: false) + ] + ) + ], + developmentRegion: "en" + ) + + // xcstringstool shouldn't be called. + let xcstringsTool = MockXCStringsTool(relativeOutputFilePaths: [:], requiredCommandLine: ["don't call me"]) + + let tester = try await TaskConstructionTester(getCore(), testProject) + + await tester.checkBuild(runDestination: .macOS, clientDelegate: xcstringsTool) { results in + results.checkNoDiagnostics() + + results.checkTarget("MyFramework") { target in + // Just copy it. + results.checkTask(.matchTarget(target), .matchRule(["Copy", "/tmp/Test/Project/build/Debug/MyFramework.framework/Versions/A/Resources/Localizable.xcstrings", "/tmp/Test/Project/Sources/Localizable.xcstrings"])) { _ in } + + // Don't do anything else with it. + results.checkNoTask(.matchTarget(target), .matchRuleType("GenerateStringSymbols")) + results.checkNoTask(.matchTarget(target), .matchRuleType("CompileXCStrings")) + results.checkNoTask(.matchTarget(target), .matchRuleType("CopyStringsFile")) + } + } + } + + // Make sure everything still works if a clever developer explicitly puts the xcstrings in Compile Sources for symbol generation. + @Test(.requireSDKs(.macOS)) + func inSources() async throws { + let testProject = try await TestProject( + "Project", + groupTree: TestGroup( + "ProjectSources", + path: "Sources", + children: [ + TestFile("MyFramework.swift"), + TestFile("Localizable.xcstrings"), + ] + ), + buildConfigurations: [ + TestBuildConfiguration("Debug", buildSettings: [ + "PRODUCT_NAME": "$(TARGET_NAME)", + ]) + ], + targets: [ + TestStandardTarget( + "MyFramework", + type: .framework, + buildConfigurations: [ + TestBuildConfiguration("Debug", buildSettings: [ + "SKIP_INSTALL": "YES", + "SWIFT_EXEC": swiftCompilerPath.str, + "SWIFT_VERSION": "5.5", + "GENERATE_INFOPLIST_FILE": "YES", + "STRING_CATALOG_GENERATE_SYMBOLS": "YES" // This is what we're primarily testing + ]), + ], + buildPhases: [ + TestSourcesBuildPhase([ + "MyFramework.swift", + "Localizable.xcstrings" + ]), + TestResourcesBuildPhase([ + + ]) + ] + ) + ], + developmentRegion: "en" + ) + + // Mock xcstringstool since it will be called for --dry-run. + // Pretend our xcstrings file contains English and German strings, and that they have variations. + let xcstringsTool = MockXCStringsTool(relativeOutputFilePaths: [ "/tmp/Test/Project/Sources/Localizable.xcstrings" : [ // input + "en.lproj/Localizable.strings", + "en.lproj/Localizable.stringsdict", + "de.lproj/Localizable.strings", + "de.lproj/Localizable.stringsdict", + ]], requiredCommandLine: [ + "xcstringstool", "compile", + "--dry-run", + "--output-directory", "/tmp/Test/Project/build/Project.build/Debug/MyFramework.build", + "/tmp/Test/Project/Sources/Localizable.xcstrings" // input file + ]) + + let tester = try await TaskConstructionTester(getCore(), testProject) + let swiftFeatures = try await self.swiftFeatures + + await tester.checkBuild(runDestination: .macOS, clientDelegate: xcstringsTool) { results in + results.checkNoDiagnostics() + + results.checkTarget("MyFramework") { target in + // There should not be any generic CpResource tasks because that would indicate that the xcstrings file is just being copied as is. + results.checkNoTask(.matchTarget(target), .matchRuleType("CpResource")) + + // First there should be symbol gen. + results.checkTask(.matchTarget(target), .matchRule(["GenerateStringSymbols", "/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/DerivedSources/GeneratedStringSymbols_Localizable.swift", "/tmp/Test/Project/Sources/Localizable.xcstrings"])) { task in + + // Input is source xcstrings file. + task.checkInputs(contain: [.path("/tmp/Test/Project/Sources/Localizable.xcstrings")]) + + // Output is .swift file. + task.checkOutputs([ + .path("/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/DerivedSources/GeneratedStringSymbols_Localizable.swift"), + ]) + + task.checkCommandLine([ + "xcstringstool", "generate-symbols", + "--language", "swift", + "--output-directory", "/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/DerivedSources", + "/tmp/Test/Project/Sources/Localizable.xcstrings" // input file + ]) + } + + // The output of that should be compiled by Swift. + let targetArchitecture = results.runDestinationTargetArchitecture + if swiftFeatures.has(.emitLocalizedStrings) { + results.checkTask(.matchTarget(target), .matchRule(["SwiftDriver Compilation", "MyFramework", "normal", targetArchitecture, "com.apple.xcode.tools.swift.compiler"])) { task in + task.checkInputs(contain: [.path("/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/DerivedSources/GeneratedStringSymbols_Localizable.swift")]) + } + } else { + results.checkTask(.matchTarget(target), .matchRule(["CompileSwiftSources", "normal", targetArchitecture, "com.apple.xcode.tools.swift.compiler"])) { task in + task.checkInputs(contain: [.path("/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/DerivedSources/GeneratedStringSymbols_Localizable.swift")]) + } + } + + // We need a task to compile the XCStrings into .strings and .stringsdict files. + results.checkTask(.matchTarget(target), .matchRule(["CompileXCStrings", "/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/", "/tmp/Test/Project/Sources/Localizable.xcstrings"])) { task in + + // Input is source xcstrings file. + task.checkInputs(contain: [.path("/tmp/Test/Project/Sources/Localizable.xcstrings")]) + + // Outputs are .strings and .stringsdicts in the TempResourcesDir. + task.checkOutputs([ + .path("/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/en.lproj/Localizable.strings"), + .path("/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/en.lproj/Localizable.stringsdict"), + .path("/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/de.lproj/Localizable.strings"), + .path("/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/de.lproj/Localizable.stringsdict"), + ]) + + task.checkCommandLine([ + "xcstringstool", "compile", + "--output-directory", "/tmp/Test/Project/build/Project.build/Debug/MyFramework.build", + "/tmp/Test/Project/Sources/Localizable.xcstrings" // input file + ]) + } + + + // Then we need the standard CopyStringsFile tasks to have the compiled .strings/dict as input. + results.checkTask(.matchTarget(target), .matchRule(["CopyStringsFile", "/tmp/Test/Project/build/Debug/MyFramework.framework/Versions/A/Resources/en.lproj/Localizable.strings", "/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/en.lproj/Localizable.strings"])) { _ in } + results.checkTask(.matchTarget(target), .matchRule(["CopyStringsFile", "/tmp/Test/Project/build/Debug/MyFramework.framework/Versions/A/Resources/en.lproj/Localizable.stringsdict", "/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/en.lproj/Localizable.stringsdict"])) { _ in } + results.checkTask(.matchTarget(target), .matchRule(["CopyStringsFile", "/tmp/Test/Project/build/Debug/MyFramework.framework/Versions/A/Resources/de.lproj/Localizable.strings", "/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/de.lproj/Localizable.strings"])) { _ in } + results.checkTask(.matchTarget(target), .matchRule(["CopyStringsFile", "/tmp/Test/Project/build/Debug/MyFramework.framework/Versions/A/Resources/de.lproj/Localizable.stringsdict", "/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/de.lproj/Localizable.stringsdict"])) { _ in } + + // And these should be the only CopyStringsFile tasks. + results.checkNoTask(.matchTarget(target), .matchRuleType("CopyStringsFile")) + } + } + } + + // Test both .xcstrings and .strings tables when symbol generation is enabled. + @Test(.requireSDKs(.macOS)) + func mixedProjectWithSymbolGeneration() async throws { + let testProject = try await TestProject( + "Project", + groupTree: TestGroup( + "ProjectSources", + path: "Sources", + children: [ + TestFile("MyFramework.swift"), + TestFile("Localizable.xcstrings"), + TestVariantGroup("CustomTable.strings", children: [ + TestFile("en.lproj/CustomTable.strings", regionVariantName: "en"), + TestFile("de.lproj/CustomTable.strings", regionVariantName: "de"), + ]), + ] + ), + buildConfigurations: [ + TestBuildConfiguration("Debug", buildSettings: [ + "PRODUCT_NAME": "$(TARGET_NAME)", + ]) + ], + targets: [ + TestStandardTarget( + "MyFramework", + type: .framework, + buildConfigurations: [ + TestBuildConfiguration("Debug", buildSettings: [ + "SKIP_INSTALL": "YES", + "SWIFT_EXEC": swiftCompilerPath.str, + "SWIFT_VERSION": "5.5", + "GENERATE_INFOPLIST_FILE": "YES", + "STRING_CATALOG_GENERATE_SYMBOLS": "YES", + ]), + ], + buildPhases: [ + TestSourcesBuildPhase([ + "MyFramework.swift" + ]), + TestResourcesBuildPhase([ + "Localizable.xcstrings", + "CustomTable.strings" + ]) + ] + ) + ], + developmentRegion: "en" + ) + + // Pretend our xcstrings files contain English and German strings, without variation. + let xcstringsTool = MockXCStringsTool(relativeOutputFilePaths: [ + "/tmp/Test/Project/Sources/Localizable.xcstrings" : [ + "en.lproj/Localizable.strings", + "de.lproj/Localizable.strings", + ], + ], requiredCommandLine: nil) + + let tester = try await TaskConstructionTester(getCore(), testProject) + + await tester.checkBuild(runDestination: .macOS, clientDelegate: xcstringsTool) { results in + results.checkNoDiagnostics() + + results.checkTarget("MyFramework") { target in + // GenerateStringSymbols + results.checkTask(.matchTarget(target), .matchRule(["GenerateStringSymbols", "/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/DerivedSources/GeneratedStringSymbols_Localizable.swift", "/tmp/Test/Project/Sources/Localizable.xcstrings"])) { _ in } + + // CompileXCStrings + results.checkTask(.matchTarget(target), .matchRule(["CompileXCStrings", "/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/", "/tmp/Test/Project/Sources/Localizable.xcstrings"])) { _ in } + results.checkNoTask(.matchTarget(target), .matchRuleType("CompileXCStrings")) + + // We should then have 4 CopyStringsFile tasks. + results.checkTask(.matchTarget(target), .matchRule(["CopyStringsFile", "/tmp/Test/Project/build/Debug/MyFramework.framework/Versions/A/Resources/en.lproj/Localizable.strings", "/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/en.lproj/Localizable.strings"])) { _ in } + results.checkTask(.matchTarget(target), .matchRule(["CopyStringsFile", "/tmp/Test/Project/build/Debug/MyFramework.framework/Versions/A/Resources/en.lproj/CustomTable.strings", "/tmp/Test/Project/Sources/en.lproj/CustomTable.strings"])) { _ in } + results.checkTask(.matchTarget(target), .matchRule(["CopyStringsFile", "/tmp/Test/Project/build/Debug/MyFramework.framework/Versions/A/Resources/de.lproj/Localizable.strings", "/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/de.lproj/Localizable.strings"])) { _ in } + results.checkTask(.matchTarget(target), .matchRule(["CopyStringsFile", "/tmp/Test/Project/build/Debug/MyFramework.framework/Versions/A/Resources/de.lproj/CustomTable.strings", "/tmp/Test/Project/Sources/de.lproj/CustomTable.strings"])) { _ in } + results.checkNoTask(.matchTarget(target), .matchRuleType("CopyStringsFile")) + } + } + } + + // Don't generate symbols if the project doesn't have Swift files. + @Test(.requireSDKs(.macOS)) + func testNoSwiftSourcesNoSymbolGen() async throws { + let testProject = try await TestProject( + "Project", + groupTree: TestGroup( + "ProjectSources", + path: "Sources", + children: [ + TestFile("MyFramework.h"), + TestFile("MyFramework.m"), + TestFile("Localizable.xcstrings"), + ] + ), + buildConfigurations: [ + TestBuildConfiguration("Debug", buildSettings: [ + "PRODUCT_NAME": "$(TARGET_NAME)", + ]) + ], + targets: [ + TestStandardTarget( + "MyFramework", + type: .framework, + buildConfigurations: [ + TestBuildConfiguration("Debug", buildSettings: [ + "SKIP_INSTALL": "YES", + "SWIFT_EXEC": swiftCompilerPath.str, + "SWIFT_VERSION": "5.5", + "GENERATE_INFOPLIST_FILE": "YES", + "STRING_CATALOG_GENERATE_SYMBOLS": "YES" + ]), + ], + buildPhases: [ + TestHeadersBuildPhase([ + "MyFramework.h" + ]), + TestSourcesBuildPhase([ + "MyFramework.m" + ]), + TestResourcesBuildPhase([ + "Localizable.xcstrings" + ]) + ] + ) + ], + developmentRegion: "en" + ) + + // Mock xcstringstool since it will be called for --dry-run. + // Pretend our xcstrings file contains English and German strings, and that they have variations. + let xcstringsTool = MockXCStringsTool(relativeOutputFilePaths: [ "/tmp/Test/Project/Sources/Localizable.xcstrings" : [ // input + "en.lproj/Localizable.strings", + "en.lproj/Localizable.stringsdict", + "de.lproj/Localizable.strings", + "de.lproj/Localizable.stringsdict", + ]], requiredCommandLine: [ + "xcstringstool", "compile", + "--dry-run", + "--output-directory", "/tmp/Test/Project/build/Project.build/Debug/MyFramework.build", + "/tmp/Test/Project/Sources/Localizable.xcstrings" // input file + ]) + + let tester = try await TaskConstructionTester(getCore(), testProject) + + await tester.checkBuild(runDestination: .macOS, clientDelegate: xcstringsTool) { results in + results.checkNoDiagnostics() + + results.checkTarget("MyFramework") { target in + // There should not be any generic CpResource tasks because that would indicate that the xcstrings file is just being copied as is. + results.checkNoTask(.matchTarget(target), .matchRuleType("CpResource")) + + // No symbol gen + results.checkNoTask(.matchRuleType("GenerateStringSymbols")) + + // We need a task to compile the XCStrings into .strings and .stringsdict files. + results.checkTask(.matchTarget(target), .matchRule(["CompileXCStrings", "/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/", "/tmp/Test/Project/Sources/Localizable.xcstrings"])) { _ in } + + + // Then we need the standard CopyStringsFile tasks to have the compiled .strings/dict as input. + results.checkTask(.matchTarget(target), .matchRule(["CopyStringsFile", "/tmp/Test/Project/build/Debug/MyFramework.framework/Versions/A/Resources/en.lproj/Localizable.strings", "/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/en.lproj/Localizable.strings"])) { _ in } + results.checkTask(.matchTarget(target), .matchRule(["CopyStringsFile", "/tmp/Test/Project/build/Debug/MyFramework.framework/Versions/A/Resources/en.lproj/Localizable.stringsdict", "/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/en.lproj/Localizable.stringsdict"])) { _ in } + results.checkTask(.matchTarget(target), .matchRule(["CopyStringsFile", "/tmp/Test/Project/build/Debug/MyFramework.framework/Versions/A/Resources/de.lproj/Localizable.strings", "/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/de.lproj/Localizable.strings"])) { _ in } + results.checkTask(.matchTarget(target), .matchRule(["CopyStringsFile", "/tmp/Test/Project/build/Debug/MyFramework.framework/Versions/A/Resources/de.lproj/Localizable.stringsdict", "/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/de.lproj/Localizable.stringsdict"])) { _ in } + + // And these should be the only CopyStringsFile tasks. + results.checkNoTask(.matchTarget(target), .matchRuleType("CopyStringsFile")) + } + } + } + + @Test(.requireSDKs(.macOS)) + func symbolGenPlusTableOverlap() async throws { + let catalog1 = TestFile("Dupe.xcstrings", guid: UUID().uuidString) + let catalog2 = TestFile("Dupe.xcstrings", guid: UUID().uuidString) + + let testProject = try await TestProject( + "Project", + groupTree: TestGroup( + "ProjectSources", + path: "Sources", + children: [ + TestFile("MyFramework.swift"), + TestFile("Localizable.xcstrings"), + TestVariantGroup("Localizable.strings", children: [ + TestFile("en.lproj/Localizable.strings", regionVariantName: "en"), + TestFile("de.lproj/Localizable.strings", regionVariantName: "de"), + ]), + catalog1, + TestGroup("Subdir", path: "Subdir", children: [ + catalog2 + ]), + ] + ), + buildConfigurations: [ + TestBuildConfiguration("Debug", buildSettings: [ + "PRODUCT_NAME": "$(TARGET_NAME)", + ]) + ], + targets: [ + TestStandardTarget( + "MyFramework", + type: .framework, + buildConfigurations: [ + TestBuildConfiguration("Debug", buildSettings: [ + "SKIP_INSTALL": "YES", + "SWIFT_EXEC": swiftCompilerPath.str, + "SWIFT_VERSION": "5.5", + "GENERATE_INFOPLIST_FILE": "YES", + "STRING_CATALOG_GENERATE_SYMBOLS": "YES" + ]), + ], + buildPhases: [ + TestSourcesBuildPhase([ + "MyFramework.swift" + ]), + TestResourcesBuildPhase([ + "Localizable.xcstrings", + "Localizable.strings", + TestBuildFile(catalog1), + TestBuildFile(catalog2), + ]) + ] + ) + ], + developmentRegion: "en" + ) + + // Pretend our xcstrings files contain English and German strings, without variation. + let xcstringsTool = MockXCStringsTool(relativeOutputFilePaths: [ + "/tmp/Test/Project/Sources/Localizable.xcstrings" : [ + "en.lproj/Localizable.strings", + "de.lproj/Localizable.strings", + ], + "/tmp/Test/Project/Sources/Dupe.xcstrings" : [ + "en.lproj/Dupe.strings", + "de.lproj/Dupe.strings", + ], + ], requiredCommandLine: nil) + + let tester = try await TaskConstructionTester(getCore(), testProject) + + await tester.checkBuild(runDestination: .macOS, clientDelegate: xcstringsTool) { results in + // This is not a supported configuration. + + results.checkError(.and(.contains("Localizable.xcstrings cannot co-exist with other .strings or .stringsdict tables with the same name."), .prefix("/tmp/Test/Project/Sources/Localizable.xcstrings"))) + + results.checkError(.contains("Cannot have multiple Dupe.xcstrings files in same target.")) + } + } + + @Test(.requireSDKs(.iOS)) + func installlocNoSymbolGeneration() async throws { + let testProject = try await TestProject( + "Project", + groupTree: TestGroup( + "ProjectSources", + path: "Sources", + children: [ + TestFile("MyFramework.swift"), + TestFile("Localizable.xcstrings"), + ] + ), + buildConfigurations: [ + TestBuildConfiguration("Release", buildSettings: [ + "PRODUCT_NAME": "$(TARGET_NAME)", + "SDKROOT" : "iphoneos", + ]) + ], + targets: [ + TestStandardTarget( + "MyFramework", + type: .framework, + buildConfigurations: [ + TestBuildConfiguration("Release", buildSettings: [ + "SKIP_INSTALL": "YES", + "SWIFT_ALLOW_INSTALL_OBJC_HEADER": "YES", + "SWIFT_EXEC": swiftCompilerPath.str, + "SWIFT_VERSION": "5.5", + "GENERATE_INFOPLIST_FILE": "YES", + "STRING_CATALOG_GENERATE_SYMBOLS": "YES", + ]), + ], + buildPhases: [ + TestSourcesBuildPhase([ + "MyFramework.swift" + ]), + TestResourcesBuildPhase([ + "Localizable.xcstrings" + ]) + ] + ) + ], + developmentRegion: "en" + ) + + // Pretend our xcstrings file contains English and German strings, and that they have variations. + let xcstringsTool = MockXCStringsTool(relativeOutputFilePaths: [ "/tmp/Test/Project/Sources/Localizable.xcstrings" : [ // input + "en.lproj/Localizable.strings", + "en.lproj/Localizable.stringsdict", + "de.lproj/Localizable.strings", + "de.lproj/Localizable.stringsdict", + ]], requiredCommandLine: [ + "xcstringstool", "compile", + "--dry-run", + "-l", "de", // installloc builds are language-specific + "--output-directory", "/tmp/Test/Project/build/Project.build/Release-iphoneos/MyFramework.build", + "/tmp/Test/Project/Sources/Localizable.xcstrings" // input file + ]) + + let tester = try await TaskConstructionTester(getCore(), testProject) + + await tester.checkBuild(BuildParameters(action: .installLoc, configuration: "Release", overrides: ["INSTALLLOC_LANGUAGE": "de"]), runDestination: .iOS, clientDelegate: xcstringsTool) { results in + results.checkNoDiagnostics() + + results.checkTarget("MyFramework") { target in + // No symbol gen in installloc. + results.checkNoTask(.matchTarget(target), .matchRuleType("GenerateStringSymbols")) + + // We need a task to compile the XCStrings into .strings and .stringsdict files. + results.checkTask(.matchTarget(target), .matchRule(["CompileXCStrings", "/tmp/Test/Project/build/Project.build/Release-iphoneos/MyFramework.build/", "/tmp/Test/Project/Sources/Localizable.xcstrings"])) { _ in } + + + // Then we need the standard CopyStringsFile tasks to have the compiled .strings/dict as input. + // Only the German variants should be copied. + results.checkTask(.matchTarget(target), .matchRule(["CopyStringsFile", "/tmp/Test/Project/build/UninstalledProducts/iphoneos/MyFramework.framework/de.lproj/Localizable.strings", "/tmp/Test/Project/build/Project.build/Release-iphoneos/MyFramework.build/de.lproj/Localizable.strings"])) { _ in } + results.checkTask(.matchTarget(target), .matchRule(["CopyStringsFile", "/tmp/Test/Project/build/UninstalledProducts/iphoneos/MyFramework.framework/de.lproj/Localizable.stringsdict", "/tmp/Test/Project/build/Project.build/Release-iphoneos/MyFramework.build/de.lproj/Localizable.stringsdict"])) { _ in } + results.checkNoTask(.matchTarget(target), .matchRuleType("CopyStringsFile")) + + // And nothing else other than the usuals. + results.checkTasks(.matchRuleType("Gate")) { _ in } + results.checkTasks(.matchRuleType("CreateBuildDirectory")) { _ in } + results.checkTasks(.matchRuleType("SymLink")) { _ in } + results.checkTasks(.matchRuleType("MkDir")) { _ in } + results.checkTasks(.matchRuleType("WriteAuxiliaryFile")) { _ in } + results.checkNoTask() + } + } + } + + // xcstrings files should be skipped entirely during installloc if they're in the Copy Files build phase. + // We can revisit if we find a legit use case where one would be needed. + @Test(.requireSDKs(.iOS)) + func installlocCopyFileNoSymbolGeneration() async throws { + let testProject = try await TestProject( + "Project", + groupTree: TestGroup( + "ProjectSources", + path: "Sources", + children: [ + TestFile("MyFramework.swift"), + TestFile("Localizable.xcstrings"), + ] + ), + buildConfigurations: [ + TestBuildConfiguration("Release", buildSettings: [ + "PRODUCT_NAME": "$(TARGET_NAME)", + "SDKROOT" : "iphoneos", + ]) + ], + targets: [ + TestStandardTarget( + "MyFramework", + type: .framework, + buildConfigurations: [ + TestBuildConfiguration("Release", buildSettings: [ + "SKIP_INSTALL": "YES", + "SWIFT_ALLOW_INSTALL_OBJC_HEADER": "YES", + "SWIFT_EXEC": swiftCompilerPath.str, + "SWIFT_VERSION": "5.5", + "GENERATE_INFOPLIST_FILE": "YES", + "STRING_CATALOG_GENERATE_SYMBOLS": "YES", + ]), + ], + buildPhases: [ + TestSourcesBuildPhase([ + "MyFramework.swift" + ]), + TestCopyFilesBuildPhase([ + "Localizable.xcstrings" + ], destinationSubfolder: .resources, onlyForDeployment: false) + ] + ) + ], + developmentRegion: "en" + ) + + // Pretend our xcstrings file contains English and German strings, and that they have variations. + let xcstringsTool = MockXCStringsTool(relativeOutputFilePaths: [ "/tmp/Test/Project/Sources/Localizable.xcstrings" : [ // input + "en.lproj/Localizable.strings", + "en.lproj/Localizable.stringsdict", + "de.lproj/Localizable.strings", + "de.lproj/Localizable.stringsdict", + ]], requiredCommandLine: [ + "xcstringstool", "compile", + "--dry-run", + "-l", "de", // installloc builds are language-specific + "--output-directory", "/tmp/Test/Project/build/Project.build/Release-iphoneos/MyFramework.build", + "/tmp/Test/Project/Sources/Localizable.xcstrings" // input file + ]) + + let tester = try await TaskConstructionTester(getCore(), testProject) + + await tester.checkBuild(BuildParameters(action: .installLoc, configuration: "Release", overrides: ["INSTALLLOC_LANGUAGE": "de"]), runDestination: .iOS, clientDelegate: xcstringsTool) { results in + results.checkNoDiagnostics() + + results.checkTarget("MyFramework") { target in + // Should be skipped. + results.checkNoTask(.matchTarget(target), .matchRuleType("CpResource")) + results.checkNoTask(.matchTarget(target), .matchRuleType("GenerateStringSymbols")) + results.checkNoTask(.matchTarget(target), .matchRuleType("CompileXCStrings")) + results.checkNoTask(.matchTarget(target), .matchRuleType("CopyStringsFile")) + } + + // And nothing else other than the usuals. + results.checkTasks(.matchRuleType("Gate")) { _ in } + results.checkTasks(.matchRuleType("CreateBuildDirectory")) { _ in } + results.checkTasks(.matchRuleType("SymLink")) { _ in } + results.checkTasks(.matchRuleType("MkDir")) { _ in } + results.checkTasks(.matchRuleType("WriteAuxiliaryFile")) { _ in } + results.checkNoTask() + } + } + + @Test(.requireSDKs(.macOS)) + func xcstringsInVariantGroupNoSymbolGeneration() async throws { + let testProject = try await TestProject( + "Project", + groupTree: TestGroup( + "ProjectSources", + path: "Sources", + children: [ + TestFile("MyFramework.swift"), + TestVariantGroup("View.xib", children: [ + TestFile("Base.lproj/View.xib", regionVariantName: "Base"), + TestFile("mul.lproj/View.xcstrings", regionVariantName: "mul"), // mul is the ISO code for multi-lingual + ]), + ] + ), + buildConfigurations: [ + TestBuildConfiguration("Debug", buildSettings: [ + "IBC_EXEC": ibtoolPath.str, + "PRODUCT_NAME": "$(TARGET_NAME)", + ]) + ], + targets: [ + TestStandardTarget( + "MyFramework", + type: .framework, + buildConfigurations: [ + TestBuildConfiguration("Debug", buildSettings: [ + "SKIP_INSTALL": "YES", + "SWIFT_EXEC": swiftCompilerPath.str, + "SWIFT_VERSION": "5.5", + "GENERATE_INFOPLIST_FILE": "YES", + "STRING_CATALOG_GENERATE_SYMBOLS": "YES", + ]), + ], + buildPhases: [ + TestSourcesBuildPhase([ + "MyFramework.swift" + ]), + TestResourcesBuildPhase([ + "View.xib", + ]) + ] + ) + ], + developmentRegion: "en" + ) + + // Pretend our xcstrings file contains French and German strings. + // We won't have English because those are in the IB file itself and not typically overridden by xcstrings. + let xcstringsTool = MockXCStringsTool(relativeOutputFilePaths: [ + "/tmp/Test/Project/Sources/mul.lproj/View.xcstrings" : [ + "fr.lproj/View.strings", + "de.lproj/View.strings", + ], + ], requiredCommandLine: nil) + + let tester = try await TaskConstructionTester(getCore(), testProject) + + await tester.checkBuild(runDestination: .macOS, clientDelegate: xcstringsTool) { results in + results.checkNoDiagnostics() + + results.checkTarget("MyFramework") { target in + // xib should get compiled by ibtool. + results.checkTask(.matchTarget(target), .matchRule(["CompileXIB", "/tmp/Test/Project/Sources/Base.lproj/View.xib"])) { _ in } + + // No symbol generation for xcstrings in variant groups because those are only used by IB. + results.checkNoTask(.matchTarget(target), .matchRuleType("GenerateStringSymbols")) + + // xcstrings should get compiled separately by xcstringstool. + results.checkTask(.matchTarget(target), .matchRule(["CompileXCStrings", "/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/", "/tmp/Test/Project/Sources/mul.lproj/View.xcstrings"])) { _ in } + + // Each xcstrings output needs a corresponding CopyStringsFile action. + results.checkTask(.matchTarget(target), .matchRule(["CopyStringsFile", "/tmp/Test/Project/build/Debug/MyFramework.framework/Versions/A/Resources/fr.lproj/View.strings", "/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/fr.lproj/View.strings"])) { _ in } + results.checkTask(.matchTarget(target), .matchRule(["CopyStringsFile", "/tmp/Test/Project/build/Debug/MyFramework.framework/Versions/A/Resources/de.lproj/View.strings", "/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/de.lproj/View.strings"])) { _ in } + + // And these should be the only CopyStringsFile tasks. + // LegacyView should not have one because ibtool is responsible for copying those .strings files. + results.checkNoTask(.matchTarget(target), .matchRuleType("CopyStringsFile")) + } + } + } + + @Test(.requireSDKs(.macOS)) + func exportLocSymbolGeneration() async throws { + let testProject = try await TestProject( + "Project", + groupTree: TestGroup( + "ProjectSources", + path: "Sources", + children: [ + TestFile("MyFramework.swift"), + TestFile("Localizable.xcstrings"), + ] + ), + buildConfigurations: [ + TestBuildConfiguration("Debug", buildSettings: [ + "PRODUCT_NAME": "$(TARGET_NAME)", + ]) + ], + targets: [ + TestStandardTarget( + "MyFramework", + type: .framework, + buildConfigurations: [ + TestBuildConfiguration("Debug", buildSettings: [ + "SKIP_INSTALL": "YES", + "SWIFT_EXEC": swiftCompilerPath.str, + "SWIFT_VERSION": "5.5", + "GENERATE_INFOPLIST_FILE": "YES", + "STRING_CATALOG_GENERATE_SYMBOLS": "YES" + ]), + ], + buildPhases: [ + TestSourcesBuildPhase([ + "MyFramework.swift" + ]), + TestResourcesBuildPhase([ + "Localizable.xcstrings" + ]) + ] + ) + ], + developmentRegion: "en" + ) + + // xcstringstool should not be called during planning since exportloc should not compile xcstrings. + let xcstringsTool = MockXCStringsTool(relativeOutputFilePaths: [ "/tmp/Test/Project/Sources/Localizable.xcstrings" : [ // input + "en.lproj/Localizable.strings", + "en.lproj/Localizable.stringsdict", + "de.lproj/Localizable.strings", + "de.lproj/Localizable.stringsdict", + ]], requiredCommandLine: [ + "don't call me" + ]) + + let tester = try await TaskConstructionTester(getCore(), testProject) + let swiftFeatures = try await self.swiftFeatures + + await tester.checkBuild(BuildParameters(action: .exportLoc, configuration: "Debug"), runDestination: .macOS, clientDelegate: xcstringsTool) { results in + results.checkNoDiagnostics() + + results.checkTarget("MyFramework") { target in + // We DO expect symbol generation. + results.checkTask(.matchTarget(target), .matchRule(["GenerateStringSymbols", "/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/DerivedSources/GeneratedStringSymbols_Localizable.swift", "/tmp/Test/Project/Sources/Localizable.xcstrings"])) { task in + + // Input is source xcstrings file. + task.checkInputs(contain: [.path("/tmp/Test/Project/Sources/Localizable.xcstrings")]) + + // Output is .swift file. + task.checkOutputs([ + .path("/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/DerivedSources/GeneratedStringSymbols_Localizable.swift"), + ]) + + task.checkCommandLine([ + "xcstringstool", "generate-symbols", + "--language", "swift", + "--output-directory", "/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/DerivedSources", + "/tmp/Test/Project/Sources/Localizable.xcstrings" // input file + ]) + } + + // The output of that should be compiled by Swift. + let targetArchitecture = results.runDestinationTargetArchitecture + if swiftFeatures.has(.emitLocalizedStrings) { + results.checkTask(.matchTarget(target), .matchRule(["SwiftDriver Compilation", "MyFramework", "normal", targetArchitecture, "com.apple.xcode.tools.swift.compiler"])) { task in + task.checkInputs(contain: [.path("/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/DerivedSources/GeneratedStringSymbols_Localizable.swift")]) + } + } else { + results.checkTask(.matchTarget(target), .matchRule(["CompileSwiftSources", "normal", targetArchitecture, "com.apple.xcode.tools.swift.compiler"])) { task in + task.checkInputs(contain: [.path("/tmp/Test/Project/build/Project.build/Debug/MyFramework.build/DerivedSources/GeneratedStringSymbols_Localizable.swift")]) + } + } + + // No actual catalog compilation. + results.checkNoTask(.matchTarget(target), .matchRuleType("CompileXCStrings")) + results.checkNoTask(.matchTarget(target), .matchRuleType("CopyStringsFile")) + } + } + } + +} diff --git a/Tests/SWBTaskConstructionTests/XCStringsTaskConstructionTests.swift b/Tests/SWBTaskConstructionTests/XCStringsTaskConstructionTests.swift index 79ec9b21..d1af5e84 100644 --- a/Tests/SWBTaskConstructionTests/XCStringsTaskConstructionTests.swift +++ b/Tests/SWBTaskConstructionTests/XCStringsTaskConstructionTests.swift @@ -22,7 +22,7 @@ import SWBTaskConstruction import SWBTestSupport // A mock xcstringstool to respond to the compile --dry-run request. -private final class MockXCStringsTool: MockTestTaskPlanningClientDelegate, @unchecked Sendable { +final class MockXCStringsTool: MockTestTaskPlanningClientDelegate, @unchecked Sendable { /// Maps input files to output files. let relativeOutputFilePaths: [String: [String]] let requiredCommandLine: [String]? @@ -1193,6 +1193,72 @@ fileprivate struct XCStringsTaskConstructionTests: CoreBasedTests { } } + // No xcstrings compilation during exportloc + @Test(.requireSDKs(.macOS)) + func exportLoc() async throws { + let testProject = try await TestProject( + "Project", + groupTree: TestGroup( + "ProjectSources", + path: "Sources", + children: [ + TestFile("MyFramework.swift"), + TestFile("Localizable.xcstrings"), + ] + ), + buildConfigurations: [ + TestBuildConfiguration("Debug", buildSettings: [ + "PRODUCT_NAME": "$(TARGET_NAME)", + ]) + ], + targets: [ + TestStandardTarget( + "MyFramework", + type: .framework, + buildConfigurations: [ + TestBuildConfiguration("Debug", buildSettings: [ + "SKIP_INSTALL": "YES", + "SWIFT_EXEC": swiftCompilerPath.str, + "SWIFT_VERSION": "5.5", + "GENERATE_INFOPLIST_FILE": "YES", + ]), + ], + buildPhases: [ + TestSourcesBuildPhase([ + "MyFramework.swift" + ]), + TestResourcesBuildPhase([ + "Localizable.xcstrings" + ]) + ] + ) + ], + developmentRegion: "en" + ) + + // xcstringstool should not be called during planning since exportloc should not compile xcstrings. + let xcstringsTool = MockXCStringsTool(relativeOutputFilePaths: [ "/tmp/Test/Project/Sources/Localizable.xcstrings" : [ // input + "en.lproj/Localizable.strings", + "en.lproj/Localizable.stringsdict", + "de.lproj/Localizable.strings", + "de.lproj/Localizable.stringsdict", + ]], requiredCommandLine: [ + "don't call me" + ]) + + let tester = try await TaskConstructionTester(getCore(), testProject) + + await tester.checkBuild(BuildParameters(action: .exportLoc, configuration: "Debug"), runDestination: .macOS, clientDelegate: xcstringsTool) { results in + results.checkNoDiagnostics() + + results.checkTarget("MyFramework") { target in + // Nothing xcstrings please + results.checkNoTask(.matchTarget(target), .matchRuleType("CompileXCStrings")) + results.checkNoTask(.matchTarget(target), .matchRuleType("CopyStringsFile")) + } + } + } + @Test(.requireSDKs(.macOS)) func forceBuildAllStrings() async throws { let testProject = try await TestProject( @@ -1295,7 +1361,7 @@ fileprivate struct XCStringsTaskConstructionTests: CoreBasedTests { } @Test(.requireSDKs(.macOS)) - func xCStringsInVariantGroup() async throws { + func xcstringsInVariantGroup() async throws { let testProject = try await TestProject( "Project", groupTree: TestGroup( @@ -1411,7 +1477,7 @@ fileprivate struct XCStringsTaskConstructionTests: CoreBasedTests { } @Test(.requireSDKs(.macOS)) - func xCStringsInVariantGroupDuringInstallloc() async throws { + func xcstringsInVariantGroupDuringInstallloc() async throws { let testProject = try await TestProject( "Project", groupTree: TestGroup( @@ -1556,7 +1622,7 @@ fileprivate struct XCStringsTaskConstructionTests: CoreBasedTests { // mul.lproj/View.xcstrings cannot co-exist with .lproj/View.strings @Test(.requireSDKs(.macOS)) - func xCStringsInVariantGroupWithTableOverlap() async throws { + func xcstringsInVariantGroupWithTableOverlap() async throws { let testProject = try await TestProject( "Project", groupTree: TestGroup( @@ -1619,7 +1685,7 @@ fileprivate struct XCStringsTaskConstructionTests: CoreBasedTests { // mul.lproj/View.xcstrings cannot co-exist with .lproj/View.xib override nib @Test(.requireSDKs(.macOS)) - func xCStringsInVariantGroupWithOverrideNib() async throws { + func xcstringsInVariantGroupWithOverrideNib() async throws { let testProject = try await TestProject( "Project", groupTree: TestGroup( diff --git a/Tests/SwiftBuildTests/LocalizationInfoSymbolGenTests.swift b/Tests/SwiftBuildTests/LocalizationInfoSymbolGenTests.swift new file mode 100644 index 00000000..3337a4ab --- /dev/null +++ b/Tests/SwiftBuildTests/LocalizationInfoSymbolGenTests.swift @@ -0,0 +1,246 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +import Foundation +import Testing +import SWBTestSupport +import SwiftBuildTestSupport + +import SWBUtil +import SwiftBuild + +@Suite(.requireHostOS(.macOS)) +fileprivate struct LocalizationInfoSymbolGenTests { + + @Test(.requireSDKs(.macOS)) + func includesSymbolFiles() async throws { + try await withTemporaryDirectory { temporaryDirectory in + try await withAsyncDeferrable { deferrable in + let tmpDir = temporaryDirectory.path + let testSession = try await TestSWBSession(temporaryDirectory: temporaryDirectory) + await deferrable.addBlock { + await #expect(throws: Never.self) { + try await testSession.close() + } + } + + let target = TestStandardTarget( + "MyApp", + type: .application, + buildConfigurations: [ + TestBuildConfiguration("Debug", buildSettings: [ + "SKIP_INSTALL": "YES", + "SWIFT_VERSION": "5.5", + "GENERATE_INFOPLIST_FILE": "YES", + "STRING_CATALOG_GENERATE_SYMBOLS": "YES", + "SDKROOT": "auto", + "SUPPORTED_PLATFORMS": "macosx", + "ONLY_ACTIVE_ARCH": "NO", + ]), + ], + buildPhases: [ + TestSourcesBuildPhase([ + "MyApp.swift", + "Supporting.swift" + ]), + TestResourcesBuildPhase([ + "Localizable.xcstrings" + ]) + ] + ) + + let testWorkspace = TestWorkspace("MyWorkspace", sourceRoot: tmpDir, projects: [ + TestProject( + "Project", + groupTree: TestGroup( + "ProjectSources", + path: "Sources", + children: [ + TestFile("MyApp.swift"), + TestFile("Supporting.swift"), + TestFile("Localizable.xcstrings"), + ] + ), + buildConfigurations: [ + TestBuildConfiguration("Debug", buildSettings: [ + "PRODUCT_NAME": "$(TARGET_NAME)", + ]) + ], + targets: [ + target + ], + developmentRegion: "en" + ) + ]) + + // Describe the workspace to the build system. + try await testSession.sendPIF(testWorkspace) + + let runDestination = SWBRunDestinationInfo.macOS + let buildParams = SWBBuildParameters(configuration: "Debug", activeRunDestination: runDestination) + var request = SWBBuildRequest() + request.add(target: SWBConfiguredTarget(guid: target.guid, parameters: buildParams)) + + let delegate = BuildOperationDelegate() + + // Now run a build (plan only) + request.buildDescriptionID = try await testSession.runBuildDescriptionCreationOperation(request: request, delegate: delegate).buildDescriptionID + + let info = try await testSession.session.generateLocalizationInfo(for: request, delegate: delegate) + + #expect(info.infoByTarget.count == 1) // 1 target + + let targetInfo = try #require(info.infoByTarget[target.guid]) + + #expect(targetInfo.generatedSymbolFilesByXCStringsPath.count == 1) + #expect(targetInfo.generatedSymbolFilesByXCStringsPath.first?.key.hasSuffix("Localizable.xcstrings") ?? false) + #expect(targetInfo.generatedSymbolFilesByXCStringsPath.first?.value.count == 1) + #expect(targetInfo.generatedSymbolFilesByXCStringsPath.first?.value.first?.hasSuffix("GeneratedStringSymbols_Localizable.swift") ?? false) + #expect(targetInfo.effectivePlatformName == "macosx") + } + } + } + + @Test(.requireSDKs(.macOS)) + func XCStringsNotNeedingBuilt() async throws { + try await withTemporaryDirectory { temporaryDirectory in + try await withAsyncDeferrable { deferrable in + let tmpDir = temporaryDirectory.path + let testSession = try await TestSWBSession(temporaryDirectory: temporaryDirectory) + await deferrable.addBlock { + await #expect(throws: Never.self) { + try await testSession.close() + } + } + + let target = TestStandardTarget( + "MyApp", + type: .application, + buildConfigurations: [ + TestBuildConfiguration("Debug", buildSettings: [ + "SKIP_INSTALL": "YES", + "SWIFT_VERSION": "5.5", + "GENERATE_INFOPLIST_FILE": "YES", + "STRING_CATALOG_GENERATE_SYMBOLS": "YES", + "SDKROOT": "auto", + "SUPPORTED_PLATFORMS": "macosx", + "ONLY_ACTIVE_ARCH": "NO" + ]), + ], + buildPhases: [ + TestSourcesBuildPhase([ + "MyApp.swift", + "Supporting.swift" + ]), + TestResourcesBuildPhase([ + "Localizable.xcstrings" + ]) + ] + ) + + let testWorkspace = TestWorkspace("MyWorkspace", sourceRoot: tmpDir, projects: [ + TestProject( + "Project", + groupTree: TestGroup( + "ProjectSources", + path: "Sources", + children: [ + TestFile("MyApp.swift"), + TestFile("Supporting.swift"), + TestFile("Localizable.xcstrings"), + ] + ), + buildConfigurations: [ + TestBuildConfiguration("Debug", buildSettings: [ + "PRODUCT_NAME": "$(TARGET_NAME)", + ]) + ], + targets: [ + target + ], + developmentRegion: "en" + ) + ]) + + // Describe the workspace to the build system. + try await testSession.sendPIF(testWorkspace) + + let runDestination = SWBRunDestinationInfo.macOS + let buildParams = SWBBuildParameters(configuration: "Debug", activeRunDestination: runDestination) + var request = SWBBuildRequest() + request.add(target: SWBConfiguredTarget(guid: target.guid, parameters: buildParams)) + + // Return empty paths from xcstringstool compile --dryrun, which means we won't actually generate any tasks for it. + // But we still need to detect its presence as part of the build inputs. + let delegate = BuildOperationDelegate(returnEmpty: true) + + // Now run a build (plan only) + request.buildDescriptionID = try await testSession.runBuildDescriptionCreationOperation(request: request, delegate: delegate).buildDescriptionID + + let info = try await testSession.session.generateLocalizationInfo(for: request, delegate: delegate) + + #expect(info.infoByTarget.count == 1) // 1 target + + let targetInfo = try #require(info.infoByTarget[target.guid]) + + #expect(targetInfo.generatedSymbolFilesByXCStringsPath.count == 1) + #expect(targetInfo.generatedSymbolFilesByXCStringsPath.first?.key.hasSuffix("Localizable.xcstrings") ?? false) + #expect(targetInfo.generatedSymbolFilesByXCStringsPath.first?.value.count == 1) + #expect(targetInfo.generatedSymbolFilesByXCStringsPath.first?.value.first?.hasSuffix("GeneratedStringSymbols_Localizable.swift") ?? false) + #expect(targetInfo.effectivePlatformName == "macosx") + } + } + } + +} + +private final class BuildOperationDelegate: SWBLocalizationDelegate { + private let delegate = TestBuildOperationDelegate() + private let returnEmpty: Bool + + init(returnEmpty: Bool = false) { + self.returnEmpty = returnEmpty + } + + func provisioningTaskInputs(targetGUID: String, provisioningSourceData: SWBProvisioningTaskInputsSourceData) async -> SWBProvisioningTaskInputs { + return await delegate.provisioningTaskInputs(targetGUID: targetGUID, provisioningSourceData: provisioningSourceData) + } + + func executeExternalTool(commandLine: [String], workingDirectory: String?, environment: [String : String]) async throws -> SWBExternalToolResult { + guard let command = commandLine.first, command.hasSuffix("xcstringstool") else { + return .deferred + } + + guard !returnEmpty else { + // We were asked to return empty, simulating an xcstrings file that does not need to build at all. + return .result(status: .exit(0), stdout: Data(), stderr: Data()) + } + + // We need to intercept and handle xcstringstool compile --dry-run commands. + // These tests are not testing the XCStringsCompiler, we just need to produce something so the build plan doesn't fail. + // So we'll just produce a single same-named .strings file. + + // Last arg is input. + guard let inputPath = commandLine.last.map(Path.init) else { + return .result(status: .exit(1), stdout: Data(), stderr: "Couldn't find input file in command line.".data(using: .utf8)!) + } + + // Second to last arg is output directory. + guard let outputDir = commandLine[safe: commandLine.endIndex - 2].map(Path.init) else { + return .result(status: .exit(1), stdout: Data(), stderr: "Couldn't find output directory in command line.".data(using: .utf8)!) + } + + let output = outputDir.join("en.lproj/\(inputPath.basenameWithoutSuffix).strings") + + return .result(status: .exit(0), stdout: Data(output.str.utf8), stderr: Data()) + } +}