Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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"
]
}
```
Expand Down
4 changes: 4 additions & 0 deletions Sources/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
],
Expand Down
13 changes: 8 additions & 5 deletions Sources/Configuration/Configuration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down Expand Up @@ -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] {
Expand Down
4 changes: 4 additions & 0 deletions Sources/Frontend/Commands/ScanCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions Sources/Indexer/IndexPipeline.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
5 changes: 4 additions & 1 deletion Sources/Indexer/IndexPlan.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,21 @@ public struct IndexPlan {
public let xibPaths: Set<FilePath>
public let xcDataModelPaths: Set<FilePath>
public let xcMappingModelPaths: Set<FilePath>
public let xcStringsPaths: Set<FilePath>

public init(
sourceFiles: [SourceFile: [IndexUnit]],
plistPaths: Set<FilePath> = [],
xibPaths: Set<FilePath> = [],
xcDataModelPaths: Set<FilePath> = [],
xcMappingModelPaths: Set<FilePath> = []
xcMappingModelPaths: Set<FilePath> = [],
xcStringsPaths: Set<FilePath> = []
) {
self.sourceFiles = sourceFiles
self.plistPaths = plistPaths
self.xibPaths = xibPaths
self.xcDataModelPaths = xcDataModelPaths
self.xcMappingModelPaths = xcMappingModelPaths
self.xcStringsPaths = xcStringsPaths
}
}
8 changes: 8 additions & 0 deletions Sources/Indexer/SwiftIndexer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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)
Expand Down
56 changes: 56 additions & 0 deletions Sources/Indexer/XCStringsIndexer.swift
Original file line number Diff line number Diff line change
@@ -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<FilePath>
private let graph: SynchronizedSourceGraph
private let logger: ContextualLogger

required init(files: Set<FilePath>, 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)")
}
}
}
36 changes: 36 additions & 0 deletions Sources/Indexer/XCStringsParser.swift
Original file line number Diff line number Diff line change
@@ -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<String> {
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
}
9 changes: 8 additions & 1 deletion Sources/ProjectDrivers/GenericProjectDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ public final class GenericProjectDriver {
let xibs: Set<String>
let xcdatamodels: Set<String>
let xcmappingmodels: Set<String>
let xcstrings: Set<String>
let testTargets: Set<String>
}

Expand All @@ -21,6 +22,7 @@ public final class GenericProjectDriver {
private let xibPaths: Set<FilePath>
private let xcDataModelsPaths: Set<FilePath>
private let xcMappingModelsPaths: Set<FilePath>
private let xcStringsPaths: Set<FilePath>
private let testTargets: Set<String>
private let configuration: Configuration

Expand All @@ -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(
Expand All @@ -45,6 +48,7 @@ public final class GenericProjectDriver {
xibPaths: xibPaths,
xcDataModelsPaths: xcDataModelPaths,
xcMappingModelsPaths: xcMappingModelPaths,
xcStringsPaths: xcStringsPaths,
testTargets: config.testTargets,
configuration: configuration
)
Expand All @@ -56,6 +60,7 @@ public final class GenericProjectDriver {
xibPaths: Set<FilePath>,
xcDataModelsPaths: Set<FilePath>,
xcMappingModelsPaths: Set<FilePath>,
xcStringsPaths: Set<FilePath>,
testTargets: Set<String>,
configuration: Configuration
) {
Expand All @@ -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
}
Expand All @@ -85,7 +91,8 @@ extension GenericProjectDriver: ProjectDriver {
plistPaths: plistPaths,
xibPaths: xibPaths,
xcDataModelPaths: xcDataModelsPaths,
xcMappingModelPaths: xcMappingModelsPaths
xcMappingModelPaths: xcMappingModelsPaths,
xcStringsPaths: xcStringsPaths
)
}
}
22 changes: 16 additions & 6 deletions Sources/ProjectDrivers/SPMProjectDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}

Expand All @@ -76,7 +78,15 @@ extension SPMProjectDriver: ProjectDriver {
}

private func interfaceBuilderFiles(from description: PackageDescription) -> Set<FilePath> {
var xibFiles: Set<FilePath> = []
resourceFiles(from: description, withExtensions: ["xib", "storyboard"])
}

private func stringCatalogFiles(from description: PackageDescription) -> Set<FilePath> {
resourceFiles(from: description, withExtensions: ["xcstrings"])
}

private func resourceFiles(from description: PackageDescription, withExtensions extensions: [String]) -> Set<FilePath> {
var files: Set<FilePath> = []

for target in description.targets {
let targetPath = pkg.path.appending(target.path)
Expand All @@ -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
}
}
4 changes: 3 additions & 1 deletion Sources/ProjectDrivers/XcodeProjectDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
}
Expand Down
Loading