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
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,32 @@ By default, Periphery does not assume that declarations accessible by the Object

Alternatively, the `--retain-objc-annotated` can be used to only retain declarations that are explicitly annotated with `@objc` or `@objcMembers`. Types that inherit `NSObject` are not retained unless they have explicit annotations. This option may uncover more unused code, but with the caveat that some of the results may be incorrect if the declaration is used in Objective-C code. To resolve these incorrect results, you must add an `@objc` annotation to the declaration.

### SPI (System Programming Interface)

Swift's `@_spi` attribute marks declarations as pseudo-private, making them accessible only to clients that explicitly import the SPI. While these declarations are technically `public`, they're intended for internal or restricted use.

When using `--retain-public` for framework projects, all public declarations are retained, including those marked with `@_spi`. However, you may want to check for unused code within specific SPIs. The `--check-spi` option allows you to specify which SPIs should be checked for unused code even when `--retain-public` is enabled.

For example, with `--retain-public --check-spi Internal`, Periphery will:
- Retain regular `public` declarations
- Retain `@_spi(Testing) public` declarations (different SPI)
- **Check** `@_spi(Internal) public` declarations for unused code

This is particularly useful for internal SPIs that should be audited for unused code while still retaining the framework's public API.

**Configuration:**

```yaml
retain_public: true
check_spi: ["Internal", "Testing"]
```

**Command line:**

```bash
periphery scan --retain-public --check-spi Internal --check-spi Testing
```

### Codable

Swift synthesizes additional code for `Codable` types that is not visible to Periphery and can result in false positives for properties not directly referenced from non-synthesized code. If your project contains many such types, you can retain all properties on `Codable` types with `--retain-codable-properties`. Alternatively, you can retain properties only on `Encodable` types with `--retain-encodable-properties`.
Expand Down
5 changes: 4 additions & 1 deletion Sources/Configuration/Configuration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ public final class Configuration {
@Setting(key: "retain_public", defaultValue: false)
public var retainPublic: Bool

@Setting(key: "check_spi", defaultValue: [])
public var checkSpi: [String]

@Setting(key: "retain_assign_only_properties", defaultValue: false)
public var retainAssignOnlyProperties: Bool

Expand Down Expand Up @@ -205,7 +208,7 @@ public final class Configuration {

lazy var settings: [any AbstractSetting] = [
$project, $schemes, $excludeTargets, $excludeTests, $indexExclude, $reportExclude, $reportInclude, $outputFormat,
$retainPublic, $retainFiles, $retainAssignOnlyProperties, $retainAssignOnlyPropertyTypes, $retainObjcAccessible,
$retainPublic, $checkSpi, $retainFiles, $retainAssignOnlyProperties, $retainAssignOnlyPropertyTypes, $retainObjcAccessible,
$retainObjcAnnotated, $retainUnusedProtocolFuncParams, $retainSwiftUIPreviews, $disableRedundantPublicAnalysis,
$disableUnusedImportAnalysis, $retainUnusedImportedModules, $externalEncodableProtocols, $externalCodableProtocols,
$externalTestCaseClasses, $verbose, $quiet, $disableUpdateCheck, $strict, $indexStorePath, $skipBuild,
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 @@ -57,6 +57,9 @@ struct ScanCommand: FrontendCommand {
@Flag(help: "Retain all public declarations, recommended for framework/library projects")
var retainPublic: Bool = defaultConfiguration.$retainPublic.defaultValue

@Option(parsing: .upToNextOption, help: "SPIs to check for unused code even when retain-public is enabled")
var checkSpi: [String] = defaultConfiguration.$checkSpi.defaultValue

@Flag(help: "Disable identification of redundant public accessibility")
var disableRedundantPublicAnalysis: Bool = defaultConfiguration.$disableRedundantPublicAnalysis.defaultValue

Expand Down Expand Up @@ -170,6 +173,7 @@ struct ScanCommand: FrontendCommand {
configuration.apply(\.$outputFormat, format)
configuration.apply(\.$retainFiles, retainFiles)
configuration.apply(\.$retainPublic, retainPublic)
configuration.apply(\.$checkSpi, checkSpi)
configuration.apply(\.$retainAssignOnlyProperties, retainAssignOnlyProperties)
configuration.apply(\.$retainAssignOnlyPropertyTypes, retainAssignOnlyPropertyTypes)
configuration.apply(\.$retainObjcAccessible, retainObjcAccessible)
Expand Down
2 changes: 1 addition & 1 deletion Sources/SourceGraph/Mutators/DynamicMemberRetainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ final class DynamicMemberRetainer: SourceGraphMutator {
}

for decl in graph.declarations(ofKinds: Declaration.Kind.functionKinds.union(Declaration.Kind.variableKinds)) {
if decl.attributes.contains("_dynamicReplacement") {
if decl.attributes.contains(where: { $0.hasPrefix("_dynamicReplacement") }) {
graph.markRetained(decl)
}
}
Expand Down
20 changes: 18 additions & 2 deletions Sources/SourceGraph/Mutators/PubliclyAccessibleRetainer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,30 @@ final class PubliclyAccessibleRetainer: SourceGraphMutator {

let publicDeclarations = declarations.filter { $0.accessibility.value == .public || $0.accessibility.value == .open }

publicDeclarations.forEach { graph.markRetained($0) }
// Only filter if checkSpi is configured (performance optimization)
let declarationsToRetain: [Declaration]
if configuration.checkSpi.isEmpty {
declarationsToRetain = publicDeclarations
} else {
declarationsToRetain = publicDeclarations.filter { decl in
!shouldCheckSpi(decl)
}
}

declarationsToRetain.forEach { graph.markRetained($0) }

// Enum cases inherit the accessibility of the enum.
publicDeclarations
declarationsToRetain
.lazy
.filter { $0.kind == .enum }
.flatMap(\.declarations)
.filter { $0.kind == .enumelement }
.forEach { graph.markRetained($0) }
}

private func shouldCheckSpi(_ declaration: Declaration) -> Bool {
configuration.checkSpi.contains { spiName in
declaration.attributes.contains("_spi\(spiName)")
}
}
}
7 changes: 5 additions & 2 deletions Sources/SyntaxAnalysis/DeclarationSyntaxVisitor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -302,8 +302,11 @@ public final class DeclarationSyntaxVisitor: PeripherySyntaxVisitor {
) {
let modifierNames = modifiers?.map(\.name.text) ?? []
let accessibility = modifierNames.mapFirst { Accessibility(rawValue: $0) }
let attributeNames = attributes?.compactMap {
AttributeSyntax($0)?.attributeName.trimmed.description ?? AttributeSyntax($0)?.attributeName.firstToken(viewMode: .sourceAccurate)?.text
let attributeNames: [String] = attributes?.compactMap { attr in
guard case let .attribute(attrSyntax) = attr else { return nil }
let name = attrSyntax.attributeName.trimmedDescription
let arguments = attrSyntax.arguments?.trimmedDescription ?? ""
return "\(name)\(arguments)"
} ?? []
let location = sourceLocationBuilder.location(at: position)
let returnClauseTypeLocations = typeNameLocations(for: returnClause)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import Foundation

// Test that @_spi(STP) public members are NOT retained by retain_public
public class FixtureClass220 {
// Regular public should be retained
public func publicFunc() {}

// @_spi(STP) public should NOT be retained (treated as internal)
@_spi(STP) public func stpSpiFunc() {}

// @_spi(OtherSPI) public should still be retained (only STP is special)
@_spi(OtherSPI) public func otherSpiFunc() {}

// Internal should not be retained
internal func internalFunc() {}

// Private should not be retained
private func privateFunc() {}
}

// Test with a struct as well
@_spi(STP) public struct FixtureStruct220 {
public func someFunc() {}
}

// Test with regular public struct for comparison
public struct FixtureStruct221 {
public func someFunc() {}
}
12 changes: 12 additions & 0 deletions Tests/PeripheryTests/RetentionTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1688,4 +1688,16 @@ final class RetentionTest: FixtureSourceGraphTestCase {
}
}
}

func testDoesNotRetainSTPSPIMembers() {
analyze(retainPublic: true, checkSpi: ["STP"]) {
assertReferenced(.class("FixtureClass220")) {
self.assertReferenced(.functionMethodInstance("publicFunc()"))
self.assertNotReferenced(.functionMethodInstance("stpSpiFunc()"))
self.assertReferenced(.functionMethodInstance("otherSpiFunc()"))
}
assertNotReferenced(.struct("FixtureStruct220"))
assertReferenced(.struct("FixtureStruct221"))
}
}
}
2 changes: 2 additions & 0 deletions Tests/Shared/FixtureSourceGraphTestCase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class FixtureSourceGraphTestCase: SPMSourceGraphTestCase {
@discardableResult
func analyze(
retainPublic: Bool = false,
checkSpi: [String] = [],
retainObjcAccessible: Bool = false,
retainObjcAnnotated: Bool = false,
disableRedundantPublicAnalysis: Bool = false,
Expand All @@ -29,6 +30,7 @@ class FixtureSourceGraphTestCase: SPMSourceGraphTestCase {
) rethrows -> [ScanResult] {
let configuration = Configuration()
configuration.retainPublic = retainPublic
configuration.checkSpi = checkSpi
configuration.retainObjcAccessible = retainObjcAccessible
configuration.retainObjcAnnotated = retainObjcAnnotated
configuration.retainAssignOnlyProperties = retainAssignOnlyProperties
Expand Down