1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310
|
//===----------------------------------------------------------------------===//
//
// 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 SWBUtil
import SWBMacro
public import SWBCore
import Foundation
public final class XCStringsCompilerSpec: GenericCompilerSpec, SpecIdentifierType, @unchecked Sendable {
public static let identifier = "com.apple.compilers.xcstrings"
public override func constructTasks(_ cbc: CommandBuildContext, _ delegate: any TaskGenerationDelegate) async {
// We expect a single input file of type xcstrings.
// However, we use a custom grouping strategy that will group the xcstrings file with any .strings, .stringsdict, or other .xcstrings with the same basename.
// Right now we don't support that case because it indicates table overlap, so error and early return.
guard cbc.inputs.count == 1 else {
assert(!cbc.inputs.isEmpty, "XCStrings task construction was passed context with no input files.")
let xcstringsFileType = cbc.producer.lookupFileType(identifier: "text.json.xcstrings")!
guard let xcstringsPath = cbc.inputs.first(where: { $0.fileType.conformsTo(xcstringsFileType) })?.absolutePath else {
assertionFailure("XCStrings task construction was passed context without an xcstrings file.")
return
}
if cbc.inputs.allSatisfy({ $0.fileType.conformsTo(xcstringsFileType) }) {
delegate.error("Cannot have multiple \(xcstringsPath.basename) files in same target.", location: .path(xcstringsPath), component: .targetIntegrity)
} else {
delegate.error("\(xcstringsPath.basename) cannot co-exist with other .strings or .stringsdict tables with the same name.", location: .path(xcstringsPath), component: .targetIntegrity)
}
return
}
// String Catalogs do not belong inside lproj directories.
// The exception is mul.lproj where "mul" stands for Multi-Lingual.
// mul.lproj is used when an xcstrings file is paired with an Interface Builder file.
if let regionVariant = cbc.input.absolutePath.regionVariantName, regionVariant != "mul" {
delegate.error("\(cbc.input.absolutePath.basename) should not be inside an lproj directory.", location: .path(cbc.input.absolutePath), component: .targetIntegrity)
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<T>(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,
// nor do we know if any strings have variations (which would require one or more .stringsdict outputs).
let outputs: [Path]
do {
// Tell the build system that we're accessing the input xcstrings file during build planning.
// This will invalidate the build plan when this xcstrings file changes.
delegate.access(path: cbc.input.absolutePath)
// We will use the command line from the xcspec, but insert --dry-run to tell xcstringstool to just tell us what files will be generated.
var dryRunCommandLine = commandLine
// xcstringstool compile --dry-run
dryRunCommandLine.insert("--dry-run", at: 2)
outputs = try await generatedFilePaths(cbc, delegate, commandLine: dryRunCommandLine, workingDirectory: cbc.producer.defaultWorkingDirectory, environment: environmentFromSpec(cbc, delegate).bindingsDictionary, executionDescription: "Compute XCStrings \(cbc.input.absolutePath.basename) output paths") { output in
return output.unsafeStringValue.split(separator: "\n").map(Path.init)
}
} catch {
emitErrorsFromDryRunFailure(error, path: cbc.input.absolutePath, delegate: delegate)
return
}
for output in outputs {
delegate.declareOutput(FileToBuild(absolutePath: output, inferringTypeUsing: cbc.producer))
}
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
)
} 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.
// So we'll use a gate task for that.
// It will effectively be a no-op at build time.
let inputNode = delegate.createNode(cbc.input.absolutePath)
let outputNode = delegate.createVirtualNode("UncompiledXCStrings \(cbc.input.absolutePath.str) \(cbc.producer.configuredTarget?.guid.stringValue ?? "NoTarget")")
delegate.createGateTask(inputs: [inputNode], output: outputNode)
}
}
/// Emits errors by parsing the errors output by `xcstringstool compile --dry-run`.
private func emitErrorsFromDryRunFailure(_ error: any Error, path: Path, delegate: any TaskGenerationDelegate) {
// An error here almost certainly means the file couldn't be read, probably because the file is invalid.
// We will attempt to get any actual error message output by xcstringstool.
// But otherwise we'll fallback to a less readable representation.
func emitFallbackError() {
delegate.error("Could not read xcstrings file: \(error)", location: .path(path, fileLocation: nil))
}
let xcstringsToolOutput: ByteString?
if let dryRunFailure = error as? RunProcessNonZeroExitError {
switch dryRunFailure.output {
case .separate(stdout: _, let stderr):
xcstringsToolOutput = stderr
case .merged(let byteString):
xcstringsToolOutput = byteString
case .none:
xcstringsToolOutput = nil
}
} else {
emitFallbackError()
return
}
// - First comes the filename, which is an optional prefix followed by a colon.
// - Not going to capture the filename because we already have it.
// - Capturing errors only.
let pattern = RegEx(patternLiteral: "^(?:[^:]+: )?error: (.*)$", options: .anchorsMatchLines)
if let outputString = xcstringsToolOutput?.stringValue {
let errorMessages = pattern.matchGroups(in: outputString).flatMap({ $0 })
for message in errorMessages {
delegate.error(message, location: .path(path, fileLocation: nil))
}
} else {
emitFallbackError()
}
}
public override func customOutputParserType(for task: any ExecutableTask) -> (any TaskOutputParser.Type)? {
return StringCatalogCompilerOutputParser.self
}
public override func generateLocalizationInfo(for task: any ExecutableTask, input: TaskGenerateLocalizationInfoInput) -> [TaskGenerateLocalizationInfoOutput] {
// 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.
// They only run in DEBUG mode.
assert(task.inputPaths.count == 1, "If you changed the implementation of this spec to produce tasks with multiple inputs, please ensure this logic is still correct.")
assert(task.inputPaths.allSatisfy({ $0.fileExtension == "xcstrings" }), "If you changed the implementation of this spec to take something other than xcstrings as input, please ensure this logic is still correct.")
// 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.
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
}
}
|