diff --git a/CHANGELOG.md b/CHANGELOG.md index 51a827e3b..dce37ebb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Exclude wrapped properties from assign-only analysis, as Periphery cannot observe the behavior of the property wrapper. - Improved the readability of result messages. - Improved Interface Builder file parsing to detect unused `@IBOutlet`, `@IBAction`, `@IBInspectable`, and `@IBSegueAction` members. Previously, all `@IB*` members were blindly retained if their containing class was referenced in a XIB or storyboard. +- Added detection of unused localized strings in String Catalogs (`.xcstrings`). Disable with `--disable-unused-localized-string-analysis`. ##### Bug Fixes diff --git a/README.md b/README.md index fcaab6dbe..604c9927f 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ - [Codable](#codable) - [XCTestCase](#xctestcase) - [Interface Builder](#interface-builder) + - [Localized Strings](#localized-strings) - [Comment Commands](#comment-commands) - [Xcode Integration](#xcode-integration) - [Excluding Files](#excluding-files) @@ -319,6 +320,22 @@ Any class that inherits `XCTestCase` is automatically retained along with its te If your project contains Interface Builder files (such as storyboards and XIBs), Periphery will take these into account when identifying unused declarations. Periphery parses these files to identify which classes, `@IBOutlet` properties, `@IBAction` methods, and `@IBInspectable` properties are actually referenced. Only those members that are connected in the Interface Builder file will be retained. Any `@IB*` members that are declared but not connected will be reported as unused. +### Localized Strings + +Periphery can identify unused localized strings in String Catalog (`.xcstrings`) files. It detects keys used via `NSLocalizedString`, `String(localized:)`, `LocalizedStringKey`, `LocalizedStringResource`, `Text`, and `Bundle.localizedString(forKey:)`. This analysis can be disabled with `--disable-unused-localized-string-analysis`. + +> [!NOTE] +> Only static string literals are detected. Dynamic keys (e.g., `String(localized: variable)`) cannot be analyzed and may result in false positives. Where possible, refactor dynamic keys to use conditional expressions with static literals: +> ```swift +> var message: String { +> if condition { +> String(localized: "message_success") +> } else { +> String(localized: "message_failure") +> } +> } +> ``` + ## Comment Commands For whatever reason, you may want to keep some unused code. Source code comment commands can be used to ignore specific declarations and exclude them from the results. @@ -470,6 +487,9 @@ Periphery can analyze projects using other build systems, though it cannot drive ], "xcmappingmodels": [ "path/to/file.xcmappingmodel" + ], + "xcstrings": [ + "path/to/Localizable.xcstrings" ] } ``` diff --git a/Sources/BUILD.bazel b/Sources/BUILD.bazel index a7278c39c..9dd94935a 100644 --- a/Sources/BUILD.bazel +++ b/Sources/BUILD.bazel @@ -26,6 +26,7 @@ swift_library( "SyntaxAnalysis/CommentCommand.swift", "SyntaxAnalysis/DeclarationSyntaxVisitor.swift", "SyntaxAnalysis/ImportSyntaxVisitor.swift", + "SyntaxAnalysis/LocalizedStringSyntaxVisitor.swift", "SyntaxAnalysis/MultiplexingSyntaxVisitor.swift", "SyntaxAnalysis/SourceLocationBuilder.swift", "SyntaxAnalysis/TypeSyntaxInspector.swift", @@ -71,6 +72,7 @@ swift_library( "SourceGraph/Mutators/GenericClassAndStructConstructorReferenceBuilder.swift", "SourceGraph/Mutators/InheritedImplicitInitializerReferenceBuilder.swift", "SourceGraph/Mutators/InterfaceBuilderPropertyRetainer.swift", + "SourceGraph/Mutators/LocalizedStringRetainer.swift", "SourceGraph/Mutators/ObjCAccessibleRetainer.swift", "SourceGraph/Mutators/PropertyWrapperRetainer.swift", "SourceGraph/Mutators/ProtocolConformanceReferenceBuilder.swift", @@ -204,6 +206,8 @@ swift_library( "Indexer/XCDataModelParser.swift", "Indexer/XCMappingModelIndexer.swift", "Indexer/XCMappingModelParser.swift", + "Indexer/XCStringsIndexer.swift", + "Indexer/XCStringsParser.swift", "Indexer/XibIndexer.swift", "Indexer/XibParser.swift", ], diff --git a/Sources/Configuration/Configuration.swift b/Sources/Configuration/Configuration.swift index 875d5978c..ed1237f41 100644 --- a/Sources/Configuration/Configuration.swift +++ b/Sources/Configuration/Configuration.swift @@ -80,6 +80,9 @@ public final class Configuration { @Setting(key: "disable_unused_import_analysis", defaultValue: false) public var disableUnusedImportAnalysis: Bool + @Setting(key: "disable_unused_localized_string_analysis", defaultValue: false) + public var disableUnusedLocalizedStringAnalysis: Bool + @Setting(key: "retain_unused_imported_modules", defaultValue: []) public var retainUnusedImportedModules: [String] @@ -210,11 +213,11 @@ public final class Configuration { $project, $schemes, $excludeTargets, $excludeTests, $indexExclude, $reportExclude, $reportInclude, $outputFormat, $retainPublic, $retainFiles, $retainAssignOnlyProperties, $retainAssignOnlyPropertyTypes, $retainObjcAccessible, $retainObjcAnnotated, $retainUnusedProtocolFuncParams, $retainSwiftUIPreviews, $disableRedundantPublicAnalysis, - $disableUnusedImportAnalysis, $retainUnusedImportedModules, $externalEncodableProtocols, $externalCodableProtocols, - $externalTestCaseClasses, $verbose, $quiet, $color, $disableUpdateCheck, $strict, $indexStorePath, - $skipBuild, $skipSchemesValidation, $cleanBuild, $buildArguments, $xcodeListArguments, $relativeResults, - $jsonPackageManifestPath, $retainCodableProperties, $retainEncodableProperties, $baseline, $writeBaseline, - $writeResults, $genericProjectConfig, $bazel, $bazelFilter, $bazelIndexStore, + $disableUnusedImportAnalysis, $disableUnusedLocalizedStringAnalysis, $retainUnusedImportedModules, $externalEncodableProtocols, + $externalCodableProtocols, $externalTestCaseClasses, $verbose, $quiet, $color, $disableUpdateCheck, $strict, + $indexStorePath, $skipBuild, $skipSchemesValidation, $cleanBuild, $buildArguments, $xcodeListArguments, + $relativeResults, $jsonPackageManifestPath, $retainCodableProperties, $retainEncodableProperties, $baseline, + $writeBaseline, $writeResults, $genericProjectConfig, $bazel, $bazelFilter, $bazelIndexStore, ] private func buildFilenameMatchers(with patterns: [String]) -> [FilenameMatcher] { diff --git a/Sources/Frontend/Commands/ScanCommand.swift b/Sources/Frontend/Commands/ScanCommand.swift index de739f3d0..a42494d2e 100644 --- a/Sources/Frontend/Commands/ScanCommand.swift +++ b/Sources/Frontend/Commands/ScanCommand.swift @@ -63,6 +63,9 @@ struct ScanCommand: FrontendCommand { @Flag(help: "Disable identification of unused imports") var disableUnusedImportAnalysis: Bool = defaultConfiguration.$disableUnusedImportAnalysis.defaultValue + @Flag(help: "Disable identification of unused localized strings from String Catalogs (xcstrings)") + var disableUnusedLocalizedStringAnalysis: Bool = defaultConfiguration.$disableUnusedLocalizedStringAnalysis.defaultValue + @Option(parsing: .upToNextOption, help: "Names of unused imported modules to retain") var retainUnusedImportedModules: [String] = defaultConfiguration.$retainUnusedImportedModules.defaultValue @@ -181,6 +184,7 @@ struct ScanCommand: FrontendCommand { configuration.apply(\.$retainSwiftUIPreviews, retainSwiftUIPreviews) configuration.apply(\.$disableRedundantPublicAnalysis, disableRedundantPublicAnalysis) configuration.apply(\.$disableUnusedImportAnalysis, disableUnusedImportAnalysis) + configuration.apply(\.$disableUnusedLocalizedStringAnalysis, disableUnusedLocalizedStringAnalysis) configuration.apply(\.$retainUnusedImportedModules, retainUnusedImportedModules) configuration.apply(\.$externalEncodableProtocols, externalEncodableProtocols) configuration.apply(\.$externalCodableProtocols, externalCodableProtocols) diff --git a/Sources/Indexer/IndexPipeline.swift b/Sources/Indexer/IndexPipeline.swift index 96fc8aaa6..4656f4d3e 100644 --- a/Sources/Indexer/IndexPipeline.swift +++ b/Sources/Indexer/IndexPipeline.swift @@ -64,6 +64,15 @@ public struct IndexPipeline { ).perform() } + if !plan.xcStringsPaths.isEmpty, !configuration.disableUnusedLocalizedStringAnalysis { + try XCStringsIndexer( + files: plan.xcStringsPaths, + graph: graph, + logger: logger, + configuration: configuration + ).perform() + } + graph.indexingComplete() } } diff --git a/Sources/Indexer/IndexPlan.swift b/Sources/Indexer/IndexPlan.swift index 57130b299..0f7c209db 100644 --- a/Sources/Indexer/IndexPlan.swift +++ b/Sources/Indexer/IndexPlan.swift @@ -8,18 +8,21 @@ public struct IndexPlan { public let xibPaths: Set public let xcDataModelPaths: Set public let xcMappingModelPaths: Set + public let xcStringsPaths: Set public init( sourceFiles: [SourceFile: [IndexUnit]], plistPaths: Set = [], xibPaths: Set = [], xcDataModelPaths: Set = [], - xcMappingModelPaths: Set = [] + xcMappingModelPaths: Set = [], + xcStringsPaths: Set = [] ) { self.sourceFiles = sourceFiles self.plistPaths = plistPaths self.xibPaths = xibPaths self.xcDataModelPaths = xcDataModelPaths self.xcMappingModelPaths = xcMappingModelPaths + self.xcStringsPaths = xcStringsPaths } } diff --git a/Sources/Indexer/SwiftIndexer.swift b/Sources/Indexer/SwiftIndexer.swift index 47488847e..5fd7b8c21 100644 --- a/Sources/Indexer/SwiftIndexer.swift +++ b/Sources/Indexer/SwiftIndexer.swift @@ -252,6 +252,9 @@ final class SwiftIndexer: Indexer { let multiplexingSyntaxVisitor = try MultiplexingSyntaxVisitor(file: sourceFile, swiftVersion: swiftVersion) let declarationSyntaxVisitor = multiplexingSyntaxVisitor.add(DeclarationSyntaxVisitor.self) let importSyntaxVisitor = multiplexingSyntaxVisitor.add(ImportSyntaxVisitor.self) + let localizedStringSyntaxVisitor: LocalizedStringSyntaxVisitor? = configuration.disableUnusedLocalizedStringAnalysis + ? nil + : multiplexingSyntaxVisitor.add(LocalizedStringSyntaxVisitor.self) multiplexingSyntaxVisitor.visit() @@ -264,6 +267,11 @@ final class SwiftIndexer: Indexer { } } + // Collect used localized string keys + if let localizedStringSyntaxVisitor, !localizedStringSyntaxVisitor.usedStringKeys.isEmpty { + graph.addUsedLocalizedStringKeys(localizedStringSyntaxVisitor.usedStringKeys) + } + associateLatentReferences() associateDanglingReferences() visitDeclarations(using: declarationSyntaxVisitor) diff --git a/Sources/Indexer/XCStringsIndexer.swift b/Sources/Indexer/XCStringsIndexer.swift new file mode 100644 index 000000000..126fb99c3 --- /dev/null +++ b/Sources/Indexer/XCStringsIndexer.swift @@ -0,0 +1,56 @@ +import Configuration +import Logger +import Shared +import SourceGraph +import SystemPackage + +final class XCStringsIndexer: Indexer { + enum XCStringsError: Error { + case failedToParse(path: FilePath, underlyingError: Error) + } + + private let files: Set + private let graph: SynchronizedSourceGraph + private let logger: ContextualLogger + + required init(files: Set, graph: SynchronizedSourceGraph, logger: ContextualLogger, configuration: Configuration) { + self.files = files + self.graph = graph + self.logger = logger.contextualized(with: "xcstrings") + super.init(configuration: configuration) + } + + func perform() throws { + let (includedFiles, excludedFiles) = filterIndexExcluded(from: files) + excludedFiles.forEach { self.logger.debug("Excluding \($0.string)") } + + try JobPool(jobs: Array(includedFiles)).forEach { [weak self] path in + guard let self else { return } + + let elapsed = try Benchmark.measure { + do { + let keys = try XCStringsParser(path: path).parse() + let sourceFile = SourceFile(path: path, modules: []) + + for key in keys { + let location = Location(file: sourceFile, line: 1, column: 1) + let declaration = Declaration( + kind: .localizedString, + usrs: ["xcstrings:\(path.string):\(key)"], + location: location + ) + declaration.name = key + + self.graph.withLock { + self.graph.addWithoutLock(declaration) + } + } + } catch { + throw XCStringsError.failedToParse(path: path, underlyingError: error) + } + } + + logger.debug("\(path.string) (\(elapsed)s)") + } + } +} diff --git a/Sources/Indexer/XCStringsParser.swift b/Sources/Indexer/XCStringsParser.swift new file mode 100644 index 000000000..244d84aa3 --- /dev/null +++ b/Sources/Indexer/XCStringsParser.swift @@ -0,0 +1,36 @@ +import Foundation +import SystemPackage + +final class XCStringsParser { + private let path: FilePath + + required init(path: FilePath) { + self.path = path + } + + func parse() throws -> Set { + guard let data = FileManager.default.contents(atPath: path.string) else { return [] } + + let catalog = try JSONDecoder().decode(XCStringsCatalog.self, from: data) + return Set(catalog.strings.keys) + } +} + +// MARK: - JSON Structure + +private struct XCStringsCatalog: Decodable { + let strings: [String: XCStringsEntry] + + private enum CodingKeys: String, CodingKey { + case strings + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + strings = try container.decodeIfPresent([String: XCStringsEntry].self, forKey: .strings) ?? [:] + } +} + +private struct XCStringsEntry: Decodable { + // We only need to know the key exists, not the actual localizations +} diff --git a/Sources/ProjectDrivers/GenericProjectDriver.swift b/Sources/ProjectDrivers/GenericProjectDriver.swift index 6807e3643..dfb870539 100644 --- a/Sources/ProjectDrivers/GenericProjectDriver.swift +++ b/Sources/ProjectDrivers/GenericProjectDriver.swift @@ -13,6 +13,7 @@ public final class GenericProjectDriver { let xibs: Set let xcdatamodels: Set let xcmappingmodels: Set + let xcstrings: Set let testTargets: Set } @@ -21,6 +22,7 @@ public final class GenericProjectDriver { private let xibPaths: Set private let xcDataModelsPaths: Set private let xcMappingModelsPaths: Set + private let xcStringsPaths: Set private let testTargets: Set private let configuration: Configuration @@ -37,6 +39,7 @@ public final class GenericProjectDriver { let xibPaths = config.xibs.mapSet { FilePath.makeAbsolute($0) } let xcDataModelPaths = config.xcdatamodels.mapSet { FilePath.makeAbsolute($0) } let xcMappingModelPaths = config.xcmappingmodels.mapSet { FilePath.makeAbsolute($0) } + let xcStringsPaths = config.xcstrings.mapSet { FilePath.makeAbsolute($0) } let indexstorePaths = config.indexstores.mapSet { FilePath.makeAbsolute($0) } self.init( @@ -45,6 +48,7 @@ public final class GenericProjectDriver { xibPaths: xibPaths, xcDataModelsPaths: xcDataModelPaths, xcMappingModelsPaths: xcMappingModelPaths, + xcStringsPaths: xcStringsPaths, testTargets: config.testTargets, configuration: configuration ) @@ -56,6 +60,7 @@ public final class GenericProjectDriver { xibPaths: Set, xcDataModelsPaths: Set, xcMappingModelsPaths: Set, + xcStringsPaths: Set, testTargets: Set, configuration: Configuration ) { @@ -64,6 +69,7 @@ public final class GenericProjectDriver { self.xibPaths = xibPaths self.xcDataModelsPaths = xcDataModelsPaths self.xcMappingModelsPaths = xcMappingModelsPaths + self.xcStringsPaths = xcStringsPaths self.testTargets = testTargets self.configuration = configuration } @@ -85,7 +91,8 @@ extension GenericProjectDriver: ProjectDriver { plistPaths: plistPaths, xibPaths: xibPaths, xcDataModelPaths: xcDataModelsPaths, - xcMappingModelPaths: xcMappingModelsPaths + xcMappingModelPaths: xcMappingModelsPaths, + xcStringsPaths: xcStringsPaths ) } } diff --git a/Sources/ProjectDrivers/SPMProjectDriver.swift b/Sources/ProjectDrivers/SPMProjectDriver.swift index cf1630518..5c0a1e2c3 100644 --- a/Sources/ProjectDrivers/SPMProjectDriver.swift +++ b/Sources/ProjectDrivers/SPMProjectDriver.swift @@ -62,10 +62,12 @@ extension SPMProjectDriver: ProjectDriver { ) let sourceFiles = try collector.collect() let xibPaths = interfaceBuilderFiles(from: description) + let xcStringsPaths = stringCatalogFiles(from: description) return IndexPlan( sourceFiles: sourceFiles, - xibPaths: xibPaths + xibPaths: xibPaths, + xcStringsPaths: xcStringsPaths ) } @@ -76,7 +78,15 @@ extension SPMProjectDriver: ProjectDriver { } private func interfaceBuilderFiles(from description: PackageDescription) -> Set { - var xibFiles: Set = [] + resourceFiles(from: description, withExtensions: ["xib", "storyboard"]) + } + + private func stringCatalogFiles(from description: PackageDescription) -> Set { + resourceFiles(from: description, withExtensions: ["xcstrings"]) + } + + private func resourceFiles(from description: PackageDescription, withExtensions extensions: [String]) -> Set { + var files: Set = [] for target in description.targets { let targetPath = pkg.path.appending(target.path) @@ -90,14 +100,14 @@ extension SPMProjectDriver: ProjectDriver { ? resourceFilePath : targetPath.appending(resource.path) - // Check if the resource path exists and is a xib/storyboard file + // Check if the resource path exists and has the expected extension guard resourcePath.exists else { continue } - guard let ext = resourcePath.extension?.lowercased(), ["xib", "storyboard"].contains(ext) else { continue } + guard let ext = resourcePath.extension?.lowercased(), extensions.contains(ext) else { continue } - xibFiles.insert(resourcePath) + files.insert(resourcePath) } } - return xibFiles + return files } } diff --git a/Sources/ProjectDrivers/XcodeProjectDriver.swift b/Sources/ProjectDrivers/XcodeProjectDriver.swift index 918fbc26a..55b2c8a1f 100644 --- a/Sources/ProjectDrivers/XcodeProjectDriver.swift +++ b/Sources/ProjectDrivers/XcodeProjectDriver.swift @@ -136,13 +136,15 @@ let xibPaths = targets.flatMapSet { $0.files(kind: .interfaceBuilder) } let xcDataModelPaths = targets.flatMapSet { $0.files(kind: .xcDataModel) } let xcMappingModelPaths = targets.flatMapSet { $0.files(kind: .xcMappingModel) } + let xcStringsPaths = targets.flatMapSet { $0.files(kind: .xcStrings) } return IndexPlan( sourceFiles: sourceFiles, plistPaths: infoPlistPaths, xibPaths: xibPaths, xcDataModelPaths: xcDataModelPaths, - xcMappingModelPaths: xcMappingModelPaths + xcMappingModelPaths: xcMappingModelPaths, + xcStringsPaths: xcStringsPaths ) } } diff --git a/Sources/SourceGraph/Elements/Declaration.swift b/Sources/SourceGraph/Elements/Declaration.swift index 7be139e0c..cece5561c 100644 --- a/Sources/SourceGraph/Elements/Declaration.swift +++ b/Sources/SourceGraph/Elements/Declaration.swift @@ -44,6 +44,7 @@ public final class Declaration { case varParameter = "var.parameter" case varStatic = "var.static" case macro + case localizedString = "localized_string" static var functionKinds: Set { Set(Kind.allCases.filter(\.isFunctionKind)) @@ -210,6 +211,8 @@ public final class Declaration { "precedence group" case .macro: "macro" + case .localizedString: + "localized string" } } } diff --git a/Sources/SourceGraph/Elements/ProjectFileKind.swift b/Sources/SourceGraph/Elements/ProjectFileKind.swift index 0042e53d2..0dd21f534 100644 --- a/Sources/SourceGraph/Elements/ProjectFileKind.swift +++ b/Sources/SourceGraph/Elements/ProjectFileKind.swift @@ -3,6 +3,7 @@ public enum ProjectFileKind: CaseIterable { case infoPlist case xcDataModel case xcMappingModel + case xcStrings public var extensions: [String] { switch self { @@ -14,6 +15,8 @@ public enum ProjectFileKind: CaseIterable { ["xcdatamodeld"] case .xcMappingModel: ["xcmappingmodel"] + case .xcStrings: + ["xcstrings"] } } } diff --git a/Sources/SourceGraph/Mutators/LocalizedStringRetainer.swift b/Sources/SourceGraph/Mutators/LocalizedStringRetainer.swift new file mode 100644 index 000000000..adf742c9a --- /dev/null +++ b/Sources/SourceGraph/Mutators/LocalizedStringRetainer.swift @@ -0,0 +1,29 @@ +import Configuration +import Foundation +import Shared + +/// Retains localized string declarations from xcstrings files that are used in Swift source code. +final class LocalizedStringRetainer: SourceGraphMutator { + private let graph: SourceGraph + + required init(graph: SourceGraph, configuration _: Configuration, swiftVersion _: SwiftVersion) { + self.graph = graph + } + + func mutate() { + let usedKeys = graph.usedLocalizedStringKeys + + guard !usedKeys.isEmpty else { return } + + // Find all xcstrings declarations and mark them as retained if their key is used + for declaration in graph.allDeclarations { + guard let name = declaration.name, + declaration.usrs.contains(where: { $0.hasPrefix("xcstrings:") }) + else { continue } + + if usedKeys.contains(name) { + graph.markRetained(declaration) + } + } + } +} diff --git a/Sources/SourceGraph/SourceGraph.swift b/Sources/SourceGraph/SourceGraph.swift index 25011738e..e5e88a3bc 100644 --- a/Sources/SourceGraph/SourceGraph.swift +++ b/Sources/SourceGraph/SourceGraph.swift @@ -20,6 +20,7 @@ public final class SourceGraph { public private(set) var unusedModuleImports: Set = [] public private(set) var assignOnlyProperties: Set = [] public private(set) var extensions: [Declaration: Set] = [:] + public private(set) var usedLocalizedStringKeys: Set = [] private var indexedModules: Set = [] private var unindexedExportedModules: Set = [] @@ -175,6 +176,10 @@ public final class SourceGraph { _ = assetReferences.insert(assetReference) } + public func addUsedLocalizedStringKeys(_ keys: Set) { + usedLocalizedStringKeys.formUnion(keys) + } + func markUsed(_ declaration: Declaration) { _ = usedDeclarations.insert(declaration) } diff --git a/Sources/SourceGraph/SourceGraphMutatorRunner.swift b/Sources/SourceGraph/SourceGraphMutatorRunner.swift index c9fc12919..7a54019de 100644 --- a/Sources/SourceGraph/SourceGraphMutatorRunner.swift +++ b/Sources/SourceGraph/SourceGraphMutatorRunner.swift @@ -34,6 +34,7 @@ public final class SourceGraphMutatorRunner { DynamicMemberRetainer.self, UnusedParameterRetainer.self, AssetReferenceRetainer.self, + LocalizedStringRetainer.self, EntryPointAttributeRetainer.self, PubliclyAccessibleRetainer.self, XCTestRetainer.self, diff --git a/Sources/SourceGraph/SynchronizedSourceGraph.swift b/Sources/SourceGraph/SynchronizedSourceGraph.swift index 2ab17640e..197d5843e 100644 --- a/Sources/SourceGraph/SynchronizedSourceGraph.swift +++ b/Sources/SourceGraph/SynchronizedSourceGraph.swift @@ -48,6 +48,12 @@ public final class SynchronizedSourceGraph { } } + public func addUsedLocalizedStringKeys(_ keys: Set) { + withLock { + graph.addUsedLocalizedStringKeys(keys) + } + } + // MARK: - Without Lock public func removeWithoutLock(_ declaration: Declaration) { diff --git a/Sources/SyntaxAnalysis/LocalizedStringSyntaxVisitor.swift b/Sources/SyntaxAnalysis/LocalizedStringSyntaxVisitor.swift new file mode 100644 index 000000000..437e2913f --- /dev/null +++ b/Sources/SyntaxAnalysis/LocalizedStringSyntaxVisitor.swift @@ -0,0 +1,105 @@ +import Foundation +import Shared +import SwiftSyntax + +/// Collects string keys used for localization in Swift source files. +/// +/// Detects usages of: +/// - `NSLocalizedString("key", ...)` +/// - `String(localized: "key", ...)` +/// - `LocalizedStringKey("key")` +/// - `LocalizedStringResource("key", ...)` +/// - `Text("key")` +public final class LocalizedStringSyntaxVisitor: PeripherySyntaxVisitor { + public private(set) var usedStringKeys: Set = [] + + public init(sourceLocationBuilder _: SourceLocationBuilder, swiftVersion _: SwiftVersion) {} + + public func visit(_ node: FunctionCallExprSyntax) { + // Get the function name being called + let calledExpression = node.calledExpression + + // Handle NSLocalizedString("key", ...) + if let identifier = calledExpression.as(DeclReferenceExprSyntax.self), + identifier.baseName.text == "NSLocalizedString" + { + if let firstArg = node.arguments.first, + let stringLiteral = firstArg.expression.as(StringLiteralExprSyntax.self), + let key = extractStringValue(from: stringLiteral) + { + usedStringKeys.insert(key) + } + return + } + + // Handle String(localized: "key", ...) or String(localized: "key", table: "table", ...) + if let identifier = calledExpression.as(DeclReferenceExprSyntax.self), + identifier.baseName.text == "String" + { + if let localizedArg = node.arguments.first(where: { $0.label?.text == "localized" }), + let stringLiteral = localizedArg.expression.as(StringLiteralExprSyntax.self), + let key = extractStringValue(from: stringLiteral) + { + usedStringKeys.insert(key) + } + return + } + + // Handle LocalizedStringKey("key") and LocalizedStringResource("key", ...) + if let identifier = calledExpression.as(DeclReferenceExprSyntax.self), + identifier.baseName.text == "LocalizedStringKey" || identifier.baseName.text == "LocalizedStringResource" + { + if let firstArg = node.arguments.first, + firstArg.label == nil, // Unlabeled first argument + let stringLiteral = firstArg.expression.as(StringLiteralExprSyntax.self), + let key = extractStringValue(from: stringLiteral) + { + usedStringKeys.insert(key) + } + return + } + + // Handle SwiftUI Text("key") - first unlabeled string argument is localized + if let identifier = calledExpression.as(DeclReferenceExprSyntax.self), + identifier.baseName.text == "Text" + { + if let firstArg = node.arguments.first, + firstArg.label == nil, // Unlabeled first argument + let stringLiteral = firstArg.expression.as(StringLiteralExprSyntax.self), + let key = extractStringValue(from: stringLiteral) + { + usedStringKeys.insert(key) + } + return + } + + // Handle member access like Bundle.main.localizedString(forKey: "key", ...) + if let memberAccess = calledExpression.as(MemberAccessExprSyntax.self), + memberAccess.declName.baseName.text == "localizedString" + { + if let forKeyArg = node.arguments.first(where: { $0.label?.text == "forKey" }), + let stringLiteral = forKeyArg.expression.as(StringLiteralExprSyntax.self), + let key = extractStringValue(from: stringLiteral) + { + usedStringKeys.insert(key) + } + return + } + } + + // MARK: - Private + + /// Extracts the string value from a string literal, handling simple cases. + /// Returns nil for string interpolations since they can't be matched to static keys. + private func extractStringValue(from literal: StringLiteralExprSyntax) -> String? { + // Only handle simple string literals, not interpolations + guard literal.segments.count == 1, + let segment = literal.segments.first, + let stringSegment = segment.as(StringSegmentSyntax.self) + else { + return nil + } + + return stringSegment.content.text + } +} diff --git a/Sources/XcodeSupport/XcodeTarget.swift b/Sources/XcodeSupport/XcodeTarget.swift index bb184b94f..6e84826d3 100644 --- a/Sources/XcodeSupport/XcodeTarget.swift +++ b/Sources/XcodeSupport/XcodeTarget.swift @@ -43,6 +43,7 @@ public final class XcodeTarget { try identifyFiles(kind: .xcDataModel, in: sourcesBuildPhases) try identifyFiles(kind: .xcMappingModel, in: sourcesBuildPhases) try identifyFiles(kind: .interfaceBuilder, in: resourcesBuildPhases) + try identifyFiles(kind: .xcStrings, in: resourcesBuildPhases) try identifyInfoPlistFiles() } diff --git a/Tests/PeripheryTests/Syntax/LocalizedStringSyntaxVisitorTest.swift b/Tests/PeripheryTests/Syntax/LocalizedStringSyntaxVisitorTest.swift new file mode 100644 index 000000000..3d53a00ce --- /dev/null +++ b/Tests/PeripheryTests/Syntax/LocalizedStringSyntaxVisitorTest.swift @@ -0,0 +1,104 @@ +import Foundation +import Logger +import Shared +@testable import SourceGraph +@testable import SyntaxAnalysis +import SystemPackage +@testable import TestShared +import XCTest + +final class LocalizedStringSyntaxVisitorTest: XCTestCase { + func testDetectsNSLocalizedString() throws { + let usedKeys = try collectUsedStringKeys(from: """ + let greeting = NSLocalizedString("hello_world", comment: "") + let farewell = NSLocalizedString("goodbye", tableName: "Other", comment: "") + """) + + XCTAssertEqual(usedKeys, ["hello_world", "goodbye"]) + } + + func testDetectsStringLocalized() throws { + let usedKeys = try collectUsedStringKeys(from: """ + let greeting = String(localized: "welcome_message") + let farewell = String(localized: "farewell_message", table: "Main") + """) + + XCTAssertEqual(usedKeys, ["welcome_message", "farewell_message"]) + } + + func testDetectsLocalizedStringKey() throws { + let usedKeys = try collectUsedStringKeys(from: """ + let key = LocalizedStringKey("settings_title") + """) + + XCTAssertEqual(usedKeys, ["settings_title"]) + } + + func testDetectsLocalizedStringResource() throws { + let usedKeys = try collectUsedStringKeys(from: """ + let resource = LocalizedStringResource("resource_key") + """) + + XCTAssertEqual(usedKeys, ["resource_key"]) + } + + func testDetectsSwiftUIText() throws { + let usedKeys = try collectUsedStringKeys(from: """ + Text("button_label") + """) + + XCTAssertEqual(usedKeys, ["button_label"]) + } + + func testDetectsBundleLocalizedString() throws { + let usedKeys = try collectUsedStringKeys(from: """ + Bundle.main.localizedString(forKey: "bundle_key", value: nil, table: nil) + """) + + XCTAssertEqual(usedKeys, ["bundle_key"]) + } + + func testIgnoresStringInterpolations() throws { + let usedKeys = try collectUsedStringKeys(from: """ + let name = "World" + let greeting = NSLocalizedString("hello \\(name)", comment: "") + """) + + XCTAssertEqual(usedKeys, []) + } + + func testMultipleKeys() throws { + let usedKeys = try collectUsedStringKeys(from: """ + NSLocalizedString("key1", comment: "") + String(localized: "key2") + Text("key3") + LocalizedStringKey("key4") + """) + + XCTAssertEqual(usedKeys, ["key1", "key2", "key3", "key4"]) + } + + // MARK: - Private + + private func collectUsedStringKeys(from source: String) throws -> Set { + // Create a temporary file with the source + let tmpDir = FileManager.default.temporaryDirectory + let tmpFile = tmpDir.appendingPathComponent("LocalizedStringTest.swift") + try source.write(to: tmpFile, atomically: true, encoding: .utf8) + + defer { + try? FileManager.default.removeItem(at: tmpFile) + } + + let path = FilePath(tmpFile.path) + let sourceFile = SourceFile(path: path, modules: ["Test"]) + + let shell = Shell(logger: Logger(quiet: true)) + let swiftVersion = SwiftVersion(shell: shell) + let multiplexingVisitor = try MultiplexingSyntaxVisitor(file: sourceFile, swiftVersion: swiftVersion) + let visitor = multiplexingVisitor.add(LocalizedStringSyntaxVisitor.self) + multiplexingVisitor.visit() + + return visitor.usedStringKeys + } +} diff --git a/Tests/PeripheryTests/XCStringsParserTest.swift b/Tests/PeripheryTests/XCStringsParserTest.swift new file mode 100644 index 000000000..13ffdcc71 --- /dev/null +++ b/Tests/PeripheryTests/XCStringsParserTest.swift @@ -0,0 +1,73 @@ +import Foundation +@testable import Indexer +import SystemPackage +import XCTest + +final class XCStringsParserTest: XCTestCase { + func testParsesStringKeys() throws { + let xcstringsContent = """ + { + "sourceLanguage": "en", + "version": "1.0", + "strings": { + "hello_world": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Hello, World!" } } + } + }, + "goodbye": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Goodbye!" } } + } + }, + "welcome_message": {} + } + } + """ + + let keys = try parseXCStrings(xcstringsContent) + XCTAssertEqual(keys, ["hello_world", "goodbye", "welcome_message"]) + } + + func testParsesEmptyStrings() throws { + let xcstringsContent = """ + { + "sourceLanguage": "en", + "version": "1.0", + "strings": {} + } + """ + + let keys = try parseXCStrings(xcstringsContent) + XCTAssertEqual(keys, []) + } + + func testParsesWithoutVersion() throws { + let xcstringsContent = """ + { + "sourceLanguage": "en", + "strings": { + "only_key": {} + } + } + """ + + let keys = try parseXCStrings(xcstringsContent) + XCTAssertEqual(keys, ["only_key"]) + } + + // MARK: - Private + + private func parseXCStrings(_ content: String) throws -> Set { + let tmpDir = FileManager.default.temporaryDirectory + let tmpFile = tmpDir.appendingPathComponent("TestStrings.xcstrings") + try content.write(to: tmpFile, atomically: true, encoding: .utf8) + + defer { + try? FileManager.default.removeItem(at: tmpFile) + } + + let path = FilePath(tmpFile.path) + return try XCStringsParser(path: path).parse() + } +} diff --git a/Tests/Shared/DeclarationDescription.swift b/Tests/Shared/DeclarationDescription.swift index 487069331..fa2f79c67 100644 --- a/Tests/Shared/DeclarationDescription.swift +++ b/Tests/Shared/DeclarationDescription.swift @@ -105,4 +105,8 @@ struct DeclarationDescription: CustomStringConvertible { static func extensionClass(_ name: String, line: Int? = nil) -> Self { self.init(kind: .extensionClass, name: name, line: line) } + + static func localizedString(_ name: String, line: Int? = nil) -> Self { + self.init(kind: .localizedString, name: name, line: line) + } } diff --git a/Tests/XcodeTests/SwiftUIProject/SwiftUIProject.xcodeproj/project.pbxproj b/Tests/XcodeTests/SwiftUIProject/SwiftUIProject.xcodeproj/project.pbxproj index a9a616845..053903234 100644 --- a/Tests/XcodeTests/SwiftUIProject/SwiftUIProject.xcodeproj/project.pbxproj +++ b/Tests/XcodeTests/SwiftUIProject/SwiftUIProject.xcodeproj/project.pbxproj @@ -12,6 +12,8 @@ 3C388873266D0CEF00E6F3AF /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3C388872266D0CEF00E6F3AF /* Preview Assets.xcassets */; }; 3C388876266D0CEF00E6F3AF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 3C388874266D0CEF00E6F3AF /* LaunchScreen.storyboard */; }; 3C38889C266D0D3700E6F3AF /* App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C38889B266D0D3700E6F3AF /* App.swift */; }; + BBBB00012EAF000000000001 /* LocalizationUsage.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBB00012EAF000000000002 /* LocalizationUsage.swift */; }; + BBBB00012EAF000000000003 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = BBBB00012EAF000000000004 /* Localizable.xcstrings */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -22,6 +24,8 @@ 3C388875266D0CEF00E6F3AF /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 3C388877266D0CEF00E6F3AF /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 3C38889B266D0D3700E6F3AF /* App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App.swift; sourceTree = ""; }; + BBBB00012EAF000000000002 /* LocalizationUsage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizationUsage.swift; sourceTree = ""; }; + BBBB00012EAF000000000004 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -60,6 +64,8 @@ 3C388877266D0CEF00E6F3AF /* Info.plist */, 3C388871266D0CEF00E6F3AF /* Preview Content */, 3C38889B266D0D3700E6F3AF /* App.swift */, + BBBB00012EAF000000000002 /* LocalizationUsage.swift */, + BBBB00012EAF000000000004 /* Localizable.xcstrings */, ); path = SwiftUIProject; sourceTree = ""; @@ -132,6 +138,7 @@ 3C388876266D0CEF00E6F3AF /* LaunchScreen.storyboard in Resources */, 3C388873266D0CEF00E6F3AF /* Preview Assets.xcassets in Resources */, 3C388870266D0CEF00E6F3AF /* Assets.xcassets in Resources */, + BBBB00012EAF000000000003 /* Localizable.xcstrings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -144,6 +151,7 @@ files = ( 3C38889C266D0D3700E6F3AF /* App.swift in Sources */, 3C38886E266D0CEE00E6F3AF /* ContentView.swift in Sources */, + BBBB00012EAF000000000001 /* LocalizationUsage.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Tests/XcodeTests/SwiftUIProject/SwiftUIProject/Localizable.xcstrings b/Tests/XcodeTests/SwiftUIProject/SwiftUIProject/Localizable.xcstrings new file mode 100644 index 000000000..0454981aa --- /dev/null +++ b/Tests/XcodeTests/SwiftUIProject/SwiftUIProject/Localizable.xcstrings @@ -0,0 +1,37 @@ +{ + "sourceLanguage": "en", + "version": "1.0", + "strings": { + "swiftui_used_key": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SwiftUI Used String" + } + } + } + }, + "swiftui_text_key": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SwiftUI Text Key" + } + } + } + }, + "swiftui_unused_key": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "SwiftUI Unused String" + } + } + } + } + } +} + diff --git a/Tests/XcodeTests/SwiftUIProject/SwiftUIProject/LocalizationUsage.swift b/Tests/XcodeTests/SwiftUIProject/SwiftUIProject/LocalizationUsage.swift new file mode 100644 index 000000000..97f91079f --- /dev/null +++ b/Tests/XcodeTests/SwiftUIProject/SwiftUIProject/LocalizationUsage.swift @@ -0,0 +1,13 @@ +import Foundation +import SwiftUI + +enum LocalizationUsage { + static func useStrings() { + _ = NSLocalizedString("swiftui_used_key", comment: "") + } + + static func textView() -> some View { + Text("swiftui_text_key") + } +} + diff --git a/Tests/XcodeTests/SwiftUIProjectTest.swift b/Tests/XcodeTests/SwiftUIProjectTest.swift index 5b32bff75..d8141271e 100644 --- a/Tests/XcodeTests/SwiftUIProjectTest.swift +++ b/Tests/XcodeTests/SwiftUIProjectTest.swift @@ -33,4 +33,10 @@ final class SwiftUIProjectTest: XcodeSourceGraphTestCase { func testRetainsUIApplicationDelegateAdaptorReferencedType() { assertReferenced(.class("AppDelegate")) } + + func testLocalizedStrings() { + assertReferenced(.localizedString("swiftui_used_key")) + assertReferenced(.localizedString("swiftui_text_key")) + assertNotReferenced(.localizedString("swiftui_unused_key")) + } } diff --git a/Tests/XcodeTests/UIKitProject/UIKitProject.xcodeproj/project.pbxproj b/Tests/XcodeTests/UIKitProject/UIKitProject.xcodeproj/project.pbxproj index 3f2dd0b2d..5ff7f5644 100644 --- a/Tests/XcodeTests/UIKitProject/UIKitProject.xcodeproj/project.pbxproj +++ b/Tests/XcodeTests/UIKitProject/UIKitProject.xcodeproj/project.pbxproj @@ -40,6 +40,8 @@ 3CE3F7CE2685DF0F0047231C /* ModelMapping.xcmappingmodel in Sources */ = {isa = PBXBuildFile; fileRef = 3CE3F7CD2685DF0F0047231C /* ModelMapping.xcmappingmodel */; }; 3CE3F7D02685E07C0047231C /* CustomEntityMigrationPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CE3F7CF2685E07C0047231C /* CustomEntityMigrationPolicy.swift */; }; 73AF86CD2968A93900BED352 /* LocalPackageTarget in Frameworks */ = {isa = PBXBuildFile; productRef = 73AF86CC2968A93900BED352 /* LocalPackageTarget */; }; + AAAA00012EAF000000000001 /* LocalizationUsage.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAAA00012EAF000000000002 /* LocalizationUsage.swift */; }; + AAAA00012EAF000000000003 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = AAAA00012EAF000000000004 /* Localizable.xcstrings */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -129,6 +131,8 @@ 3CE3F7CF2685E07C0047231C /* CustomEntityMigrationPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomEntityMigrationPolicy.swift; sourceTree = ""; }; 3CFFB5A82AEF8FDE002EFB86 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; 3CFFB5AA2AEF8FDE002EFB86 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; + AAAA00012EAF000000000002 /* LocalizationUsage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizationUsage.swift; sourceTree = ""; }; + AAAA00012EAF000000000004 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -263,6 +267,8 @@ 3CE3F7CA2685DEFB0047231C /* OldModel.xcdatamodeld */, 3CE3F7CD2685DF0F0047231C /* ModelMapping.xcmappingmodel */, 3CE3F7CF2685E07C0047231C /* CustomEntityMigrationPolicy.swift */, + AAAA00012EAF000000000002 /* LocalizationUsage.swift */, + AAAA00012EAF000000000004 /* Localizable.xcstrings */, ); path = UIKitProject; sourceTree = ""; @@ -501,6 +507,7 @@ 3C9B06C425542F2500E45614 /* Launch Screen.storyboard in Resources */, 3C849662255405B000900DA9 /* Assets.xcassets in Resources */, 3C9B06E725547C3800E45614 /* StoryboardViewController.storyboard in Resources */, + AAAA00012EAF000000000003 /* Localizable.xcstrings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -556,6 +563,7 @@ 3CD8FF922683ACBB001951CD /* EntityValueTransformer.swift in Sources */, 3C84966E255405F600900DA9 /* SceneDelegate.swift in Sources */, 3CE3F7CE2685DF0F0047231C /* ModelMapping.xcmappingmodel in Sources */, + AAAA00012EAF000000000001 /* LocalizationUsage.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Tests/XcodeTests/UIKitProject/UIKitProject/Localizable.xcstrings b/Tests/XcodeTests/UIKitProject/UIKitProject/Localizable.xcstrings new file mode 100644 index 000000000..4b3e6a33b --- /dev/null +++ b/Tests/XcodeTests/UIKitProject/UIKitProject/Localizable.xcstrings @@ -0,0 +1,37 @@ +{ + "sourceLanguage": "en", + "version": "1.0", + "strings": { + "used_string_key": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Used String" + } + } + } + }, + "another_used_key": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Another Used String" + } + } + } + }, + "unused_string_key": { + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Unused String" + } + } + } + } + } +} + diff --git a/Tests/XcodeTests/UIKitProject/UIKitProject/LocalizationUsage.swift b/Tests/XcodeTests/UIKitProject/UIKitProject/LocalizationUsage.swift new file mode 100644 index 000000000..06d69e3dd --- /dev/null +++ b/Tests/XcodeTests/UIKitProject/UIKitProject/LocalizationUsage.swift @@ -0,0 +1,9 @@ +import Foundation + +enum LocalizationUsage { + static func useStrings() { + _ = NSLocalizedString("used_string_key", comment: "") + _ = String(localized: "another_used_key") + } +} + diff --git a/Tests/XcodeTests/UIKitProjectTest.swift b/Tests/XcodeTests/UIKitProjectTest.swift index 7b7f39b3c..f9013b83c 100644 --- a/Tests/XcodeTests/UIKitProjectTest.swift +++ b/Tests/XcodeTests/UIKitProjectTest.swift @@ -120,4 +120,10 @@ final class UIKitProjectTest: XcodeSourceGraphTestCase { assertReferenced(.struct("LocalPackageUsedType")) assertNotReferenced(.struct("LocalPackageUnusedType")) } + + func testLocalizedStrings() { + assertReferenced(.localizedString("used_string_key")) + assertReferenced(.localizedString("another_used_key")) + assertNotReferenced(.localizedString("unused_string_key")) + } } diff --git a/baselines/linux-bazel.json b/baselines/linux-bazel.json index c54f8ef14..eb6a3daa4 100644 --- a/baselines/linux-bazel.json +++ b/baselines/linux-bazel.json @@ -3,6 +3,7 @@ "usrs": [ "s:10Extensions4Glob33_78772790CB7745B67917FD65D1BCE611LLC", "s:11SourceGraph15ProjectFileKindO10extensionsSaySSGvp", + "s:11SourceGraph15ProjectFileKindO9xcStringsyA2CmF", "s:13SystemPackage8FilePathV10ExtensionsE4globyShyACGSSFZ", "s:13SystemPackage8FilePathV10ExtensionsE5chdir7closureyyyKXE_tKF", "s:14SyntaxAnalysis21UnusedParameterParserV5parse4file0F9ProtocolsSayAA8FunctionVG11SourceGraph0J4FileC_SbtKFZ", diff --git a/baselines/linux.json b/baselines/linux.json index 13418f45e..0e1a99508 100644 --- a/baselines/linux.json +++ b/baselines/linux.json @@ -3,7 +3,9 @@ "usrs": [ "import-TestShared-Tests/PeripheryTests/ObjcAccessibleRetentionTest.swift:2:1", "import-TestShared-Tests/PeripheryTests/ObjcAnnotatedRetentionTest.swift:2:1", + "s:10TestShared22DeclarationDescriptionV15localizedString_4lineACSS_SiSgtFZ", "s:11SourceGraph15ProjectFileKindO10extensionsSaySSGvp", + "s:11SourceGraph15ProjectFileKindO9xcStringsyA2CmF", "s:6Shared14SetupSelectionO", "s:6Shared17SetupGuideHelpersC6select8multipleAA0B9SelectionOSaySSG_tF", "s:SS10ExtensionsE17withEscapedQuotesSSvp", diff --git a/bazel/internal/scan/scan.bzl b/bazel/internal/scan/scan.bzl index e46cce083..9eb53bf10 100644 --- a/bazel/internal/scan/scan.bzl +++ b/bazel/internal/scan/scan.bzl @@ -15,6 +15,7 @@ PeripheryInfo = provider( "xibs": "A depset of .xib and .storyboard files.", "xcdatamodels": "A depset of .xcdatamodel files.", "xcmappingmodels": "A depset of .xcmappingmodel files", + "xcstrings": "A depset of .xcstrings files.", "test_targets": "A depset of test only target names.", }, ) @@ -44,6 +45,7 @@ def _scan_inputs_aspect_impl(target, ctx): xibs = [] xcdatamodels = [] xcmappingmodels = [] + xcstrings = [] if not target.label.workspace_name: # Ignore external deps modules = [] @@ -90,6 +92,11 @@ def _scan_inputs_aspect_impl(target, ctx): elif ".xcmappingmodel" in resource.path: xcmappingmodels.append(resource) + if hasattr(info, "strings"): + for resource in info.strings[0][2].to_list(): + if resource.path.endswith(".xcstrings"): + xcstrings.append(resource) + deps = getattr(ctx.rule.attr, "deps", []) providers = [dep[PeripheryInfo] for dep in deps] swift_target = getattr(ctx.rule.attr, "swift_target", None) @@ -125,6 +132,10 @@ def _scan_inputs_aspect_impl(target, ctx): direct = xcmappingmodels, transitive = [provider.xcmappingmodels for provider in providers], ) + xcstrings_depset = depset( + direct = xcstrings, + transitive = [provider.xcstrings for provider in providers], + ) return [ PeripheryInfo( @@ -134,6 +145,7 @@ def _scan_inputs_aspect_impl(target, ctx): xibs = xibs_depset, xcdatamodels = xcdatamodels_depset, xcmappingmodels = xcmappingmodels_depset, + xcstrings = xcstrings_depset, test_targets = test_targets_depset, ), ] @@ -146,6 +158,7 @@ def scan_impl(ctx): xibs_set = sets.make() xcdatamodels_set = sets.make() xcmappingmodels_set = sets.make() + xcstrings_set = sets.make() test_targets_set = sets.make() for dep in ctx.attr.deps: @@ -155,6 +168,7 @@ def scan_impl(ctx): xibs_set = sets.union(xibs_set, sets.make(dep[PeripheryInfo].xibs.to_list())) xcdatamodels_set = sets.union(xcdatamodels_set, sets.make(dep[PeripheryInfo].xcdatamodels.to_list())) xcmappingmodels_set = sets.union(xcmappingmodels_set, sets.make(dep[PeripheryInfo].xcmappingmodels.to_list())) + xcstrings_set = sets.union(xcstrings_set, sets.make(dep[PeripheryInfo].xcstrings.to_list())) test_targets_set = sets.union(test_targets_set, sets.make(dep[PeripheryInfo].test_targets.to_list())) swift_srcs = sets.to_list(swift_srcs_set) @@ -163,6 +177,7 @@ def scan_impl(ctx): xibs = sets.to_list(xibs_set) xcdatamodels = sets.to_list(xcdatamodels_set) xcmappingmodels = sets.to_list(xcmappingmodels_set) + xcstrings = sets.to_list(xcstrings_set) test_targets = sets.to_list(test_targets_set) indexstores_config = [file.path for file in indexstores] @@ -175,6 +190,7 @@ def scan_impl(ctx): xibs = [file.path for file in xibs], xcdatamodels = [file.path for file in xcdatamodels], xcmappingmodels = [file.path for file in xcmappingmodels], + xcstrings = [file.path for file in xcstrings], test_targets = test_targets, ) @@ -200,7 +216,7 @@ def scan_impl(ctx): # Swift sources are not included in the generated project file, yet they are referenced # in the indexstores and will be read by Periphery, and therefore must be present in # the runfiles. - files = swift_srcs + indexstores + plists + xibs + xcdatamodels + xcmappingmodels + [periphery], + files = swift_srcs + indexstores + plists + xibs + xcdatamodels + xcmappingmodels + xcstrings + [periphery], ), )