From f1fe3e68155abcd92c474a3561f0fe3b6d651e3a Mon Sep 17 00:00:00 2001 From: Rishabh Tayal Date: Mon, 20 Apr 2026 11:24:05 -0500 Subject: [PATCH] added support for scanning image assets --- README.md | 3 + Sources/BUILD.bazel | 5 ++ Sources/Indexer/AssetCatalogIndexer.swift | 36 +++++++++++ Sources/Indexer/AssetCatalogParser.swift | 44 +++++++++++++ Sources/Indexer/IndexPipeline.swift | 9 +++ Sources/Indexer/IndexPlan.swift | 3 + Sources/Indexer/SwiftIndexer.swift | 5 ++ Sources/Indexer/XibIndexer.swift | 5 +- Sources/Indexer/XibParser.swift | 44 ++++++++++++- Sources/PeripheryKit/ScanResultBuilder.swift | 1 + .../ProjectDrivers/GenericProjectDriver.swift | 7 +++ Sources/ProjectDrivers/SPMProjectDriver.swift | 45 ++++++++++++++ .../ProjectDrivers/XcodeProjectDriver.swift | 2 + .../SourceGraph/Elements/Declaration.swift | 3 + .../Elements/ImageAssetReference.swift | 18 ++++++ .../Elements/ProjectFileKind.swift | 3 + .../ImageAssetReferenceRetainer.swift | 19 ++++++ Sources/SourceGraph/SourceGraph.swift | 19 ++++++ .../SourceGraphMutatorRunner.swift | 1 + .../ImageAssetReferenceSyntaxVisitor.swift | 62 +++++++++++++++++++ .../MultiplexingSyntaxVisitor.swift | 13 ++++ .../DeterminismRegressionTest.swift | 32 ++++++++++ Tests/Shared/SourceGraphTestCase.swift | 1 + 23 files changed, 375 insertions(+), 5 deletions(-) create mode 100644 Sources/Indexer/AssetCatalogIndexer.swift create mode 100644 Sources/Indexer/AssetCatalogParser.swift create mode 100644 Sources/SourceGraph/Elements/ImageAssetReference.swift create mode 100644 Sources/SourceGraph/Mutators/ImageAssetReferenceRetainer.swift create mode 100644 Sources/SyntaxAnalysis/ImageAssetReferenceSyntaxVisitor.swift diff --git a/README.md b/README.md index 17d3ad87a8..184f933e36 100644 --- a/README.md +++ b/README.md @@ -480,6 +480,9 @@ Periphery can analyze projects using other build systems, though it cannot drive "plists": [ "path/to/file.plist" ], + "asset_catalogs": [ + "path/to/Assets.xcassets" + ], "xibs": [ "path/to/file.xib", "path/to/file.storyboard" diff --git a/Sources/BUILD.bazel b/Sources/BUILD.bazel index 24286f675b..68ac26b4b1 100644 --- a/Sources/BUILD.bazel +++ b/Sources/BUILD.bazel @@ -25,6 +25,7 @@ swift_library( srcs = [ "SyntaxAnalysis/CommentCommand.swift", "SyntaxAnalysis/DeclarationSyntaxVisitor.swift", + "SyntaxAnalysis/ImageAssetReferenceSyntaxVisitor.swift", "SyntaxAnalysis/ImportSyntaxVisitor.swift", "SyntaxAnalysis/MultiplexingSyntaxVisitor.swift", "SyntaxAnalysis/SourceLOCCounter.swift", @@ -52,6 +53,7 @@ swift_library( "SourceGraph/Elements/Declaration.swift", "SourceGraph/Elements/DeclarationAttribute.swift", "SourceGraph/Elements/ImportStatement.swift", + "SourceGraph/Elements/ImageAssetReference.swift", "SourceGraph/Elements/Location.swift", "SourceGraph/Elements/ProjectFileKind.swift", "SourceGraph/Elements/Reference.swift", @@ -74,6 +76,7 @@ swift_library( "SourceGraph/Mutators/GenericClassAndStructConstructorReferenceBuilder.swift", "SourceGraph/Mutators/InheritedImplicitInitializerReferenceBuilder.swift", "SourceGraph/Mutators/InterfaceBuilderPropertyRetainer.swift", + "SourceGraph/Mutators/ImageAssetReferenceRetainer.swift", "SourceGraph/Mutators/ObjCAccessibleRetainer.swift", "SourceGraph/Mutators/PropertyWrapperRetainer.swift", "SourceGraph/Mutators/ProtocolConformanceReferenceBuilder.swift", @@ -195,6 +198,8 @@ swift_library( swift_library( name = "Indexer", srcs = [ + "Indexer/AssetCatalogIndexer.swift", + "Indexer/AssetCatalogParser.swift", "Indexer/IndexPipeline.swift", "Indexer/IndexPlan.swift", "Indexer/Indexer.swift", diff --git a/Sources/Indexer/AssetCatalogIndexer.swift b/Sources/Indexer/AssetCatalogIndexer.swift new file mode 100644 index 0000000000..821a5b2fdd --- /dev/null +++ b/Sources/Indexer/AssetCatalogIndexer.swift @@ -0,0 +1,36 @@ +import Configuration +import Logger +import Shared +import SourceGraph +import SystemPackage + +final class AssetCatalogIndexer: Indexer { + private let assetCatalogs: Set + private let graph: SourceGraphMutex + private let logger: ContextualLogger + + required init(assetCatalogs: Set, graph: SourceGraphMutex, logger: ContextualLogger, configuration: Configuration) { + self.assetCatalogs = assetCatalogs + self.graph = graph + self.logger = logger.contextualized(with: "asset-catalog") + super.init(configuration: configuration) + } + + func perform() throws { + let (includedFiles, excludedFiles) = filterIndexExcluded(from: assetCatalogs) + excludedFiles.forEach { self.logger.debug("Excluding \($0.string)") } + + try JobPool(jobs: Array(includedFiles)).forEach { [weak self] assetCatalog in + guard let self else { return } + + let elapsed = Benchmark.measure { + let imageAssets = AssetCatalogParser(path: assetCatalog).parse() + self.graph.withLock { graph in + imageAssets.forEach { graph.addImageAsset($0) } + } + } + + logger.debug("\(assetCatalog.string) (\(elapsed)s)") + } + } +} diff --git a/Sources/Indexer/AssetCatalogParser.swift b/Sources/Indexer/AssetCatalogParser.swift new file mode 100644 index 0000000000..d5bdddc5f1 --- /dev/null +++ b/Sources/Indexer/AssetCatalogParser.swift @@ -0,0 +1,44 @@ +import Foundation +import SourceGraph +import SystemPackage + +final class AssetCatalogParser { + private let path: FilePath + + required init(path: FilePath) { + self.path = path + } + + func parse() -> Set { + guard let enumerator = FileManager.default.enumerator( + at: path.url, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) else { return [] } + + var declarations = Set() + + for case let url as URL in enumerator { + guard url.pathExtension == "imageset" else { continue } + + let imageSetPath = FilePath(url.path) + let name = url.deletingPathExtension().lastPathComponent + let contentsPath = imageSetPath.appending("Contents.json") + let locationPath = FileManager.default.fileExists(atPath: contentsPath.lexicallyNormalized().string) ? contentsPath : imageSetPath + let locationFile = SourceFile(path: locationPath, modules: []) + let location = Location(file: locationFile, line: 1, column: 1) + let usr = "image-asset-\(imageSetPath.lexicallyNormalized().string)" + + declarations.insert( + Declaration( + name: name, + kind: .imageAsset, + usrs: [usr], + location: location + ) + ) + } + + return declarations + } +} diff --git a/Sources/Indexer/IndexPipeline.swift b/Sources/Indexer/IndexPipeline.swift index 93879159a4..66213249d0 100644 --- a/Sources/Indexer/IndexPipeline.swift +++ b/Sources/Indexer/IndexPipeline.swift @@ -28,6 +28,15 @@ public struct IndexPipeline { swiftVersion: swiftVersion ).perform() + if !plan.assetCatalogPaths.isEmpty { + try AssetCatalogIndexer( + assetCatalogs: plan.assetCatalogPaths, + graph: graph, + logger: logger, + configuration: configuration + ).perform() + } + if !plan.plistPaths.isEmpty { try InfoPlistIndexer( infoPlistFiles: plan.plistPaths, diff --git a/Sources/Indexer/IndexPlan.swift b/Sources/Indexer/IndexPlan.swift index 57130b299f..11570b1b32 100644 --- a/Sources/Indexer/IndexPlan.swift +++ b/Sources/Indexer/IndexPlan.swift @@ -4,6 +4,7 @@ import SystemPackage public struct IndexPlan { public let sourceFiles: [SourceFile: [IndexUnit]] + public let assetCatalogPaths: Set public let plistPaths: Set public let xibPaths: Set public let xcDataModelPaths: Set @@ -11,12 +12,14 @@ public struct IndexPlan { public init( sourceFiles: [SourceFile: [IndexUnit]], + assetCatalogPaths: Set = [], plistPaths: Set = [], xibPaths: Set = [], xcDataModelPaths: Set = [], xcMappingModelPaths: Set = [] ) { self.sourceFiles = sourceFiles + self.assetCatalogPaths = assetCatalogPaths self.plistPaths = plistPaths self.xibPaths = xibPaths self.xcDataModelPaths = xcDataModelPaths diff --git a/Sources/Indexer/SwiftIndexer.swift b/Sources/Indexer/SwiftIndexer.swift index d5209788d8..27bea73aae 100644 --- a/Sources/Indexer/SwiftIndexer.swift +++ b/Sources/Indexer/SwiftIndexer.swift @@ -260,6 +260,7 @@ 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 imageAssetReferenceSyntaxVisitor = multiplexingSyntaxVisitor.add(ImageAssetReferenceSyntaxVisitor.self) multiplexingSyntaxVisitor.visit() @@ -279,6 +280,10 @@ final class SwiftIndexer: Indexer { } } + graph.withLock { graph in + imageAssetReferenceSyntaxVisitor.references.forEach { graph.add($0) } + } + associateLatentReferences() associateDanglingReferences() visitDeclarations(using: declarationSyntaxVisitor) diff --git a/Sources/Indexer/XibIndexer.swift b/Sources/Indexer/XibIndexer.swift index 0cf837be0f..d346047b25 100644 --- a/Sources/Indexer/XibIndexer.swift +++ b/Sources/Indexer/XibIndexer.swift @@ -29,10 +29,11 @@ final class XibIndexer: Indexer { let elapsed = try Benchmark.measure { do { - let refs = try XibParser(path: xibPath) + let result = try XibParser(path: xibPath) .parse() self.graph.withLock { graph in - refs.forEach { graph.add($0) } + result.assetReferences.forEach { graph.add($0) } + result.imageAssetReferences.forEach { graph.add($0) } } } catch { throw XibError.failedToParse(path: xibPath, underlyingError: error) diff --git a/Sources/Indexer/XibParser.swift b/Sources/Indexer/XibParser.swift index b9dd016609..c046d05b98 100644 --- a/Sources/Indexer/XibParser.swift +++ b/Sources/Indexer/XibParser.swift @@ -10,8 +10,15 @@ final class XibParser { self.path = path } - func parse() throws -> [AssetReference] { - guard let data = FileManager.default.contents(atPath: path.string) else { return [] } + struct Result { + let assetReferences: [AssetReference] + let imageAssetReferences: Set + } + + func parse() throws -> Result { + guard let data = FileManager.default.contents(atPath: path.string) else { + return Result(assetReferences: [], imageAssetReferences: []) + } let structure = try AEXMLDocument(xml: data) @@ -22,8 +29,10 @@ final class XibParser { // Collect all references with their outlets, actions, and runtime attributes var referencesByClass: [String: (outlets: Set, actions: Set, runtimeAttributes: Set)] = [:] collectReferences(from: structure.root, idToCustomClass: idToCustomClass, into: &referencesByClass) + var imageReferences: Set = [] + collectImageReferences(from: structure.root, into: &imageReferences) - return referencesByClass.map { className, members in + let assetReferences = referencesByClass.map { className, members in AssetReference( absoluteName: className, source: .interfaceBuilder, @@ -32,6 +41,8 @@ final class XibParser { runtimeAttributes: Array(members.runtimeAttributes) ) } + + return Result(assetReferences: assetReferences, imageAssetReferences: imageReferences) } // MARK: - Private @@ -180,4 +191,31 @@ final class XibParser { return path } + + private func collectImageReferences(from element: AEXMLElement, into imageReferences: inout Set) { + for (key, value) in element.attributes { + let lowercasedKey = key.lowercased() + + if lowercasedKey.contains("image"), lowercasedKey != "systemimage" { + imageReferences.insert(imageReference(named: value)) + } + } + + if element.name == "image", let name = element.attributes["name"] { + imageReferences.insert(imageReference(named: name)) + } + + for child in element.children { + collectImageReferences(from: child, into: &imageReferences) + } + } + + private func imageReference(named name: String) -> ImageAssetReference { + let file = SourceFile(path: path, modules: []) + return ImageAssetReference( + name: name, + location: Location(file: file, line: 1, column: 1), + source: .interfaceBuilder + ) + } } diff --git a/Sources/PeripheryKit/ScanResultBuilder.swift b/Sources/PeripheryKit/ScanResultBuilder.swift index c0093d5c77..976148da48 100644 --- a/Sources/PeripheryKit/ScanResultBuilder.swift +++ b/Sources/PeripheryKit/ScanResultBuilder.swift @@ -8,6 +8,7 @@ public enum ScanResultBuilder { let removableDeclarations = graph.unusedDeclarations .subtracting(assignOnlyProperties) .union(graph.unusedModuleImports) + .union(graph.unusedImageAssets) let redundantProtocols = graph.redundantProtocols.filter { !removableDeclarations.contains($0.0) } let redundantPublicAccessibility = graph.redundantPublicAccessibility.filter { !removableDeclarations.contains($0.0) } diff --git a/Sources/ProjectDrivers/GenericProjectDriver.swift b/Sources/ProjectDrivers/GenericProjectDriver.swift index f791e692bb..a2357151ee 100644 --- a/Sources/ProjectDrivers/GenericProjectDriver.swift +++ b/Sources/ProjectDrivers/GenericProjectDriver.swift @@ -9,6 +9,7 @@ import SystemPackage public final class GenericProjectDriver { struct GenericConfig: Decodable { let indexstores: Set + let assetCatalogs: Set? let plists: Set let xibs: Set let xcdatamodels: Set @@ -17,6 +18,7 @@ public final class GenericProjectDriver { } private let indexstorePaths: Set + private let assetCatalogPaths: Set private let plistPaths: Set private let xibPaths: Set private let xcDataModelsPaths: Set @@ -33,6 +35,7 @@ public final class GenericProjectDriver { decoder.keyDecodingStrategy = .convertFromSnakeCase let data = try Data(contentsOf: genericProjectConfig.url) let config = try decoder.decode(GenericConfig.self, from: data) + let assetCatalogPaths = (config.assetCatalogs ?? []).mapSet { FilePath.makeAbsolute($0) } let plistPaths = config.plists.mapSet { FilePath.makeAbsolute($0) } let xibPaths = config.xibs.mapSet { FilePath.makeAbsolute($0) } let xcDataModelPaths = config.xcdatamodels.mapSet { FilePath.makeAbsolute($0) } @@ -41,6 +44,7 @@ public final class GenericProjectDriver { self.init( indexstorePaths: indexstorePaths, + assetCatalogPaths: assetCatalogPaths, plistPaths: plistPaths, xibPaths: xibPaths, xcDataModelsPaths: xcDataModelPaths, @@ -52,6 +56,7 @@ public final class GenericProjectDriver { private init( indexstorePaths: Set, + assetCatalogPaths: Set, plistPaths: Set, xibPaths: Set, xcDataModelsPaths: Set, @@ -60,6 +65,7 @@ public final class GenericProjectDriver { configuration: Configuration ) { self.indexstorePaths = indexstorePaths + self.assetCatalogPaths = assetCatalogPaths self.plistPaths = plistPaths self.xibPaths = xibPaths self.xcDataModelsPaths = xcDataModelsPaths @@ -82,6 +88,7 @@ extension GenericProjectDriver: ProjectDriver { return IndexPlan( sourceFiles: sourceFiles, + assetCatalogPaths: assetCatalogPaths, plistPaths: plistPaths, xibPaths: xibPaths, xcDataModelPaths: xcDataModelsPaths, diff --git a/Sources/ProjectDrivers/SPMProjectDriver.swift b/Sources/ProjectDrivers/SPMProjectDriver.swift index 2d9898b44c..0a13030fc7 100644 --- a/Sources/ProjectDrivers/SPMProjectDriver.swift +++ b/Sources/ProjectDrivers/SPMProjectDriver.swift @@ -61,10 +61,12 @@ extension SPMProjectDriver: ProjectDriver { configuration: configuration ) let sourceFiles = try collector.collect() + let assetCatalogPaths = assetCatalogs(from: description) let xibPaths = interfaceBuilderFiles(from: description) return IndexPlan( sourceFiles: sourceFiles, + assetCatalogPaths: assetCatalogPaths, xibPaths: xibPaths ) } @@ -100,4 +102,47 @@ extension SPMProjectDriver: ProjectDriver { return xibFiles } + + private func assetCatalogs(from description: PackageDescription) -> Set { + var catalogs: Set = [] + + for target in description.targets { + let targetPath = pkg.path.appending(target.path) + + guard let resources = target.resources else { continue } + + for resource in resources { + let resourceFilePath = FilePath(resource.path) + let resourcePath: FilePath = resourceFilePath.isAbsolute + ? resourceFilePath + : targetPath.appending(resource.path) + + guard resourcePath.exists else { continue } + + if resourcePath.extension?.lowercased() == "xcassets" { + catalogs.insert(resourcePath) + } else { + catalogs.formUnion(assetCatalogs(in: resourcePath)) + } + } + } + + return catalogs + } + + private func assetCatalogs(in path: FilePath) -> Set { + guard let enumerator = FileManager.default.enumerator( + at: path.url, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) else { return [] } + + var paths: Set = [] + + for case let url as URL in enumerator where url.pathExtension == "xcassets" { + paths.insert(FilePath(url.path)) + } + + return paths + } } diff --git a/Sources/ProjectDrivers/XcodeProjectDriver.swift b/Sources/ProjectDrivers/XcodeProjectDriver.swift index 943fe363cf..24b73de802 100644 --- a/Sources/ProjectDrivers/XcodeProjectDriver.swift +++ b/Sources/ProjectDrivers/XcodeProjectDriver.swift @@ -132,6 +132,7 @@ configuration: configuration ) let sourceFiles = try collector.collect() + let assetCatalogPaths = targets.flatMapSet { $0.files(kind: .assetCatalog) } let infoPlistPaths = targets.flatMapSet { $0.files(kind: .infoPlist) } let xibPaths = targets.flatMapSet { $0.files(kind: .interfaceBuilder) } let xcDataModelPaths = targets.flatMapSet { $0.files(kind: .xcDataModel) } @@ -139,6 +140,7 @@ return IndexPlan( sourceFiles: sourceFiles, + assetCatalogPaths: assetCatalogPaths, plistPaths: infoPlistPaths, xibPaths: xibPaths, xcDataModelPaths: xcDataModelPaths, diff --git a/Sources/SourceGraph/Elements/Declaration.swift b/Sources/SourceGraph/Elements/Declaration.swift index 382c58403d..4b4071c414 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 imageAsset = "image.asset" static var functionKinds: Set { Set(Kind.allCases.filter(\.isFunctionKind)) @@ -210,6 +211,8 @@ public final class Declaration { "precedence group" case .macro: "macro" + case .imageAsset: + "image asset" } } } diff --git a/Sources/SourceGraph/Elements/ImageAssetReference.swift b/Sources/SourceGraph/Elements/ImageAssetReference.swift new file mode 100644 index 0000000000..b6ea95b434 --- /dev/null +++ b/Sources/SourceGraph/Elements/ImageAssetReference.swift @@ -0,0 +1,18 @@ +import Foundation + +public struct ImageAssetReference: Hashable { + public enum Source { + case swift + case interfaceBuilder + } + + public init(name: String, location: Location, source: Source) { + self.name = name + self.location = location + self.source = source + } + + public let name: String + public let location: Location + public let source: Source +} diff --git a/Sources/SourceGraph/Elements/ProjectFileKind.swift b/Sources/SourceGraph/Elements/ProjectFileKind.swift index 0042e53d2b..d112fe2171 100644 --- a/Sources/SourceGraph/Elements/ProjectFileKind.swift +++ b/Sources/SourceGraph/Elements/ProjectFileKind.swift @@ -1,4 +1,5 @@ public enum ProjectFileKind: CaseIterable { + case assetCatalog case interfaceBuilder case infoPlist case xcDataModel @@ -6,6 +7,8 @@ public enum ProjectFileKind: CaseIterable { public var extensions: [String] { switch self { + case .assetCatalog: + ["xcassets"] case .interfaceBuilder: ["xib", "storyboard"] case .infoPlist: diff --git a/Sources/SourceGraph/Mutators/ImageAssetReferenceRetainer.swift b/Sources/SourceGraph/Mutators/ImageAssetReferenceRetainer.swift new file mode 100644 index 0000000000..cbe5b50b8b --- /dev/null +++ b/Sources/SourceGraph/Mutators/ImageAssetReferenceRetainer.swift @@ -0,0 +1,19 @@ +import Configuration +import Foundation +import Shared + +final class ImageAssetReferenceRetainer: SourceGraphMutator { + private let graph: SourceGraph + + required init(graph: SourceGraph, configuration _: Configuration, swiftVersion _: SwiftVersion) { + self.graph = graph + } + + func mutate() { + let referencedNames = Set(graph.imageAssetReferences.map(\.name)) + + for imageAsset in graph.imageAssets where referencedNames.contains(imageAsset.name) { + graph.markUsedImageAsset(imageAsset) + } + } +} diff --git a/Sources/SourceGraph/SourceGraph.swift b/Sources/SourceGraph/SourceGraph.swift index 63534fb5c5..829ceec81e 100644 --- a/Sources/SourceGraph/SourceGraph.swift +++ b/Sources/SourceGraph/SourceGraph.swift @@ -14,6 +14,9 @@ public final class SourceGraph { public private(set) var retainedDeclarations: Set = [] public private(set) var ignoredDeclarations: Set = [] public private(set) var assetReferences: Set = [] + public private(set) var imageAssets: Set = [] + public private(set) var imageAssetReferences: Set = [] + public private(set) var usedImageAssets: Set = [] public private(set) var mainAttributedDeclarations: Set = [] public private(set) var allReferencesByUsr: [String: Set] = [:] public private(set) var indexedSourceFiles: [SourceFile] = [] @@ -48,6 +51,10 @@ public final class SourceGraph { allDeclarations.subtracting(usedDeclarations) } + public var unusedImageAssets: Set { + imageAssets.subtracting(usedImageAssets) + } + public func declarations(ofKind kind: Declaration.Kind) -> Set { allDeclarationsByKind[kind] ?? [] } @@ -225,6 +232,18 @@ public final class SourceGraph { _ = assetReferences.insert(assetReference) } + public func addImageAsset(_ declaration: Declaration) { + _ = imageAssets.insert(declaration) + } + + public func add(_ imageAssetReference: ImageAssetReference) { + _ = imageAssetReferences.insert(imageAssetReference) + } + + func markUsedImageAsset(_ declaration: Declaration) { + _ = usedImageAssets.insert(declaration) + } + func markUsed(_ declaration: Declaration) { _ = usedDeclarations.insert(declaration) } diff --git a/Sources/SourceGraph/SourceGraphMutatorRunner.swift b/Sources/SourceGraph/SourceGraphMutatorRunner.swift index c9fc129198..fa19918ff9 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, + ImageAssetReferenceRetainer.self, EntryPointAttributeRetainer.self, PubliclyAccessibleRetainer.self, XCTestRetainer.self, diff --git a/Sources/SyntaxAnalysis/ImageAssetReferenceSyntaxVisitor.swift b/Sources/SyntaxAnalysis/ImageAssetReferenceSyntaxVisitor.swift new file mode 100644 index 0000000000..9bb396e6b1 --- /dev/null +++ b/Sources/SyntaxAnalysis/ImageAssetReferenceSyntaxVisitor.swift @@ -0,0 +1,62 @@ +import Foundation +import Shared +import SourceGraph +import SwiftSyntax + +public final class ImageAssetReferenceSyntaxVisitor: PeripherySyntaxVisitor { + public private(set) var references: Set = [] + + private let sourceLocationBuilder: SourceLocationBuilder + + public required init(sourceLocationBuilder: SourceLocationBuilder, swiftVersion _: SwiftVersion) { + self.sourceLocationBuilder = sourceLocationBuilder + } + + public func visit(_ node: FunctionCallExprSyntax) { + guard isImageAssetCall(node), + let literal = firstStringLiteralArgument(in: node) + else { return } + + references.insert( + ImageAssetReference( + name: literal.value, + location: sourceLocationBuilder.location(at: literal.position), + source: .swift + ) + ) + } + + // MARK: - Private + + private func isImageAssetCall(_ node: FunctionCallExprSyntax) -> Bool { + let calledExpression = node.calledExpression.trimmedDescription + let firstArgument = node.arguments.first + let firstLabel = firstArgument?.label?.text + + if ["Image", "UIImage", "NSImage"].contains(calledExpression) { + return firstLabel == nil || firstLabel == "named" + } + + if calledExpression.hasSuffix(".init") { + let initializedType = calledExpression.dropLast(".init".count) + if ["Image", "UIImage", "NSImage"].contains(String(initializedType)) { + return firstLabel == nil || firstLabel == "named" + } + } + + if ["ImageResource", "UIImageResource"].contains(calledExpression) { + return firstLabel == "name" + } + + return false + } + + private func firstStringLiteralArgument(in node: FunctionCallExprSyntax) -> (value: String, position: AbsolutePosition)? { + guard let expression = node.arguments.first?.expression.as(StringLiteralExprSyntax.self), + expression.segments.count == 1, + let segment = expression.segments.first?.as(StringSegmentSyntax.self) + else { return nil } + + return (segment.content.text, expression.positionAfterSkippingLeadingTrivia) + } +} diff --git a/Sources/SyntaxAnalysis/MultiplexingSyntaxVisitor.swift b/Sources/SyntaxAnalysis/MultiplexingSyntaxVisitor.swift index 9222f86c05..2bed61bdbf 100644 --- a/Sources/SyntaxAnalysis/MultiplexingSyntaxVisitor.swift +++ b/Sources/SyntaxAnalysis/MultiplexingSyntaxVisitor.swift @@ -27,6 +27,7 @@ public protocol PeripherySyntaxVisitor { func visit(_ node: ImportDeclSyntax) func visit(_ node: OptionalBindingConditionSyntax) func visit(_ node: FunctionCallExprSyntax) + func visit(_ node: StringLiteralExprSyntax) func visitPost(_ node: ActorDeclSyntax) func visitPost(_ node: ClassDeclSyntax) @@ -47,6 +48,7 @@ public protocol PeripherySyntaxVisitor { func visitPost(_ node: ImportDeclSyntax) func visitPost(_ node: OptionalBindingConditionSyntax) func visitPost(_ node: FunctionCallExprSyntax) + func visitPost(_ node: StringLiteralExprSyntax) } public extension PeripherySyntaxVisitor { @@ -69,6 +71,7 @@ public extension PeripherySyntaxVisitor { func visit(_: ImportDeclSyntax) {} func visit(_: OptionalBindingConditionSyntax) {} func visit(_: FunctionCallExprSyntax) {} + func visit(_: StringLiteralExprSyntax) {} func visitPost(_: ActorDeclSyntax) {} func visitPost(_: ClassDeclSyntax) {} @@ -89,6 +92,7 @@ public extension PeripherySyntaxVisitor { func visitPost(_: ImportDeclSyntax) {} func visitPost(_: OptionalBindingConditionSyntax) {} func visitPost(_: FunctionCallExprSyntax) {} + func visitPost(_: StringLiteralExprSyntax) {} } public final class MultiplexingSyntaxVisitor: SyntaxVisitor { @@ -219,6 +223,11 @@ public final class MultiplexingSyntaxVisitor: SyntaxVisitor { return .visitChildren } + override public func visit(_ node: StringLiteralExprSyntax) -> SyntaxVisitorContinueKind { + visitors.forEach { $0.visit(node) } + return .visitChildren + } + override public func visitPost(_ node: ActorDeclSyntax) { visitors.forEach { $0.visitPost(node) } } @@ -294,4 +303,8 @@ public final class MultiplexingSyntaxVisitor: SyntaxVisitor { override public func visitPost(_ node: FunctionCallExprSyntax) { visitors.forEach { $0.visitPost(node) } } + + override public func visitPost(_ node: StringLiteralExprSyntax) { + visitors.forEach { $0.visitPost(node) } + } } diff --git a/Tests/PeripheryTests/DeterminismRegressionTest.swift b/Tests/PeripheryTests/DeterminismRegressionTest.swift index a513525007..6ddea586d2 100644 --- a/Tests/PeripheryTests/DeterminismRegressionTest.swift +++ b/Tests/PeripheryTests/DeterminismRegressionTest.swift @@ -224,6 +224,38 @@ final class DeterminismRegressionTest: XCTestCase { XCTAssertNil(graph.redundantPublicAccessibility[outletDecl]) } + func testImageAssetReferenceRetainerMarksReferencedImageAssets() { + let graph = makeGraph() + + let used = makeDeclaration( + kind: .imageAsset, + name: "UsedImage", + usr: "image-used", + location: makeLocation("/tmp/Assets.xcassets/UsedImage.imageset/Contents.json", module: "") + ) + let unused = makeDeclaration( + kind: .imageAsset, + name: "UnusedImage", + usr: "image-unused", + location: makeLocation("/tmp/Assets.xcassets/UnusedImage.imageset/Contents.json", module: "") + ) + + graph.addImageAsset(used) + graph.addImageAsset(unused) + graph.add( + ImageAssetReference( + name: "UsedImage", + location: makeLocation("/tmp/View.swift", module: "Feature"), + source: .swift + ) + ) + + ImageAssetReferenceRetainer(graph: graph, configuration: Configuration(), swiftVersion: makeSwiftVersion()).mutate() + + XCTAssertTrue(graph.usedImageAssets.contains(used)) + XCTAssertEqual(graph.unusedImageAssets, [unused]) + } + func testProtocolConformanceReferenceBuilderDeterministicallySelectsSuperclassImplementation() { let graph = makeGraph() let configuration = Configuration() diff --git a/Tests/Shared/SourceGraphTestCase.swift b/Tests/Shared/SourceGraphTestCase.swift index cbc52395f9..5e43e812b0 100644 --- a/Tests/Shared/SourceGraphTestCase.swift +++ b/Tests/Shared/SourceGraphTestCase.swift @@ -49,6 +49,7 @@ open class SourceGraphTestCase: XCTestCase { if let sourceFiles { newPlan = IndexPlan( sourceFiles: plan.sourceFiles.filter { sourceFiles.contains($0.key.path) }, + assetCatalogPaths: plan.assetCatalogPaths, plistPaths: plan.plistPaths, xibPaths: plan.xibPaths, xcDataModelPaths: plan.xcDataModelPaths,