diff --git a/CHANGELOG.md b/CHANGELOG.md index 51a827e3bc..dce37ebb50 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 fcaab6dbe3..604c9927fa 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 a7278c39c2..9dd94935ae 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 875d5978c6..ed1237f413 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 de739f3d05..a42494d2e9 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 96fc8aaa6f..4656f4d3ec 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 57130b299f..0f7c209dbd 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 47488847ee..5fd7b8c216 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 0000000000..126fb99c31 --- /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 0000000000..244d84aa32 --- /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 6807e36434..dfb8705390 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 cf1630518d..5c0a1e2c30 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 918fbc26af..55b2c8a1fc 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 7be139e0cc..cece5561c9 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 0042e53d2b..0dd21f5346 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 0000000000..adf742c9a2 --- /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 25011738e6..e5e88a3bce 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 c9fc129198..7a54019de4 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 2ab17640e5..197d5843e1 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 0000000000..437e2913f3 --- /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 bb184b94fd..6e84826d3c 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 0000000000..3d53a00cef --- /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 0000000000..13ffdcc71b --- /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 487069331b..fa2f79c67a 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 a9a6168452..053903234a 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 0000000000..0454981aac --- /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 0000000000..97f91079fd --- /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 5b32bff755..d8141271e6 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 3f2dd0b2d2..5ff7f56440 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 0000000000..4b3e6a33ba --- /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 0000000000..06d69e3dd6 --- /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 7b7f39b3cf..f9013b83cb 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 c54f8ef141..eb6a3daa48 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 13418f45e4..0e1a995082 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 e46cce083d..9eb53bf107 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], ), )