File: XCStringsCompiler.swift

package info (click to toggle)
swiftlang 6.2.3-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 2,856,264 kB
  • sloc: cpp: 9,995,718; ansic: 2,234,019; asm: 1,092,167; python: 313,940; objc: 82,726; f90: 80,126; lisp: 38,373; pascal: 25,580; sh: 20,378; ml: 5,058; perl: 4,751; makefile: 4,725; awk: 3,535; javascript: 3,018; xml: 918; fortran: 664; cs: 573; ruby: 396
file content (310 lines) | stat: -rw-r--r-- 15,737 bytes parent folder | download
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
    }

}