├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── Sources ├── weak-self-check │ ├── Extension │ │ ├── ReportType+.swift │ │ └── FileManager+.swift │ ├── Config.swift │ └── main.swift └── WeakSelfCheckCore │ ├── Model │ └── WhiteListElement.swift │ ├── SelfAccessDetector.swift │ ├── Extension │ ├── MemberAccessExprSyntax+.swift │ ├── ClosureCapture+.swift │ ├── String+.swift │ ├── FunctionParameterSyntax+.swift │ ├── TypeSyntaxProtocol+.swift │ ├── IndexStoreSymbol+.swift │ └── Syntax+.swift │ ├── Util │ └── demangle.swift │ ├── ClosureWeakSelfChecker.swift │ └── WeakSelfChecker.swift ├── .swift-weak-self-check.yml ├── .github └── workflows │ └── ci.yml ├── LICENSE ├── Package.resolved ├── Package.swift ├── README.md ├── Plugins ├── WeakSelfCheckBuildToolPlugin │ └── plugin.swift └── WeakSelfCheckCommandPlugin │ └── plugin.swift └── Tests └── WeakSelfCheckCoreTests └── WeakSelfCheckCoreTests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Sources/weak-self-check/Extension/ReportType+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ReportType+.swift 3 | // 4 | // 5 | // Created by p-x9 on 2024/06/04 6 | // 7 | // 8 | 9 | import Foundation 10 | import ArgumentParser 11 | import SourceReporter 12 | import WeakSelfCheckCore 13 | 14 | extension ReportType: @retroactive ExpressibleByArgument {} 15 | -------------------------------------------------------------------------------- /.swift-weak-self-check.yml: -------------------------------------------------------------------------------- 1 | reportType: error 2 | slent: true 3 | whiteList: 4 | - parentPattern: "^DispatchQueue\\..*" 5 | functionName: "^(async|sync).*" 6 | - parentPattern: "UIView" 7 | functionName: "animate" 8 | - parentPattern: "Task" 9 | functionName: ".*" 10 | - functionName: "Task" 11 | excludedFiles: 12 | - ".*/\\.build/.*" 13 | - ".*/vendor/bundle/.*" 14 | - ".*/Pods/.*" 15 | -------------------------------------------------------------------------------- /Sources/weak-self-check/Config.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Config.swift 3 | // 4 | // 5 | // Created by p-x9 on 2024/06/04 6 | // 7 | // 8 | 9 | import Foundation 10 | import SourceReporter 11 | import WeakSelfCheckCore 12 | 13 | public struct Config: Codable { 14 | public var reportType: ReportType? 15 | public var slent: Bool? 16 | public var quick: Bool? 17 | public var whiteList: [WhiteListElement]? 18 | public var excludedFiles: [String]? 19 | } 20 | -------------------------------------------------------------------------------- /Sources/WeakSelfCheckCore/Model/WhiteListElement.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WhiteListElement.swift 3 | // 4 | // 5 | // Created by p-x9 on 2024/06/04 6 | // 7 | // 8 | 9 | import Foundation 10 | 11 | public struct WhiteListElement: Codable { 12 | public let parentPattern: String? 13 | public let functionName: String 14 | 15 | public init(parentPattern: String?, functionName: String) { 16 | self.parentPattern = parentPattern 17 | self.functionName = functionName 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/weak-self-check/Extension/FileManager+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileManager+.swift 3 | // 4 | // 5 | // Created by p-x9 on 2024/06/04 6 | // 7 | // 8 | 9 | import Foundation 10 | 11 | extension FileManager { 12 | func isDirectory(_ path: String) -> Bool { 13 | var isDir: ObjCBool = false 14 | if fileExists(atPath: path, isDirectory: &isDir) { 15 | return isDir.boolValue 16 | } 17 | return false 18 | } 19 | 20 | func isDirectory(_ url: URL) -> Bool { 21 | var isDir: ObjCBool = false 22 | if fileExists(atPath: url.path, isDirectory: &isDir) { 23 | return isDir.boolValue 24 | } 25 | return false 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | paths-ignore: 8 | - README.md 9 | - LICENSE 10 | pull_request: 11 | paths-ignore: 12 | - README.md 13 | - LICENSE 14 | workflow_dispatch: 15 | 16 | permissions: 17 | contents: read 18 | 19 | env: 20 | DEVELOPER_DIR: /Applications/Xcode_16.3.app 21 | 22 | jobs: 23 | build: 24 | name: Build & Test 25 | runs-on: macos-15 26 | steps: 27 | - name: Checkout 28 | uses: actions/checkout@v4 29 | 30 | - name: Select Xcode 16 31 | run: sudo xcode-select -s /Applications/Xcode_16.3.app 32 | 33 | - name: Build 34 | run: swift build 35 | 36 | - name: Test 37 | run: swift test 38 | -------------------------------------------------------------------------------- /Sources/WeakSelfCheckCore/SelfAccessDetector.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SelfAccessDetector.swift 3 | // 4 | // 5 | // Created by p-x9 on 2024/06/04 6 | // 7 | // 8 | 9 | import Foundation 10 | import SwiftSyntax 11 | 12 | public enum SelfAccessDetector { 13 | public static func check(_ node: SyntaxProtocol) -> Bool { 14 | let visitor = _SelfAccessDetectSyntaxVisitor(viewMode: .all) 15 | visitor.walk(node) 16 | return visitor.isSelfUsed 17 | } 18 | } 19 | 20 | fileprivate final class _SelfAccessDetectSyntaxVisitor: SyntaxVisitor { 21 | public private(set) var isSelfUsed: Bool = false 22 | 23 | override func visit(_ node: DeclReferenceExprSyntax) -> SyntaxVisitorContinueKind { 24 | if node.baseName.tokenKind == .keyword(.`self`) { 25 | isSelfUsed = true 26 | return .skipChildren 27 | } 28 | return .visitChildren 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/WeakSelfCheckCore/Extension/MemberAccessExprSyntax+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // MemberAccessExprSyntax+.swift 3 | // 4 | // 5 | // Created by p-x9 on 2024/06/04 6 | // 7 | // 8 | 9 | import Foundation 10 | import SwiftSyntax 11 | 12 | extension MemberAccessExprSyntax { 13 | var chainedExppressions: [ExprSyntax] { 14 | let declName: ExprSyntax = .init(declName) 15 | guard let base else { 16 | return [declName] 17 | } 18 | 19 | if let memberAccess = base.as(MemberAccessExprSyntax.self) { 20 | return memberAccess.chainedExppressions + [declName] 21 | } 22 | 23 | if let function = base.as(FunctionCallExprSyntax.self) { 24 | if let memberAccess = function.calledExpression.as(MemberAccessExprSyntax.self) { 25 | return memberAccess.chainedExppressions + [declName] 26 | } 27 | } 28 | 29 | return [base, declName] 30 | } 31 | 32 | var chainedMemberNames: [String] { 33 | chainedExppressions.map(\.trimmed.description) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 p-x9 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Sources/WeakSelfCheckCore/Extension/ClosureCapture+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ClosureCapture.swift 3 | // 4 | // 5 | // Created by p-x9 on 2024/06/04 6 | // 7 | // 8 | 9 | import Foundation 10 | import SwiftSyntax 11 | 12 | extension ClosureCaptureSyntax { 13 | var isWeakSelf: Bool { 14 | guard let specifier else { return false } 15 | #if canImport(SwiftSyntax601) 16 | let name = self.name 17 | #else 18 | guard let expression = expression.as(DeclReferenceExprSyntax.self) else { 19 | return false 20 | } 21 | let name = expression.baseName 22 | #endif 23 | if specifier.specifier.tokenKind == .keyword(.weak), 24 | name.tokenKind == .keyword(.`self`) { 25 | return true 26 | } 27 | return false 28 | } 29 | 30 | var isUnownedSelf: Bool { 31 | guard let specifier else { return false } 32 | #if canImport(SwiftSyntax601) 33 | let name = self.name 34 | #else 35 | guard let expression = expression.as(DeclReferenceExprSyntax.self) else { 36 | return false 37 | } 38 | let name = expression.baseName 39 | #endif 40 | if specifier.specifier.tokenKind == .keyword(.unowned), 41 | name.tokenKind == .keyword(.`self`) { 42 | return true 43 | } 44 | return false 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Sources/WeakSelfCheckCore/Extension/String+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // String+.swift 3 | // 4 | // 5 | // Created by p-x9 on 2024/06/04 6 | // 7 | // 8 | 9 | import Foundation 10 | 11 | extension String { 12 | package func matches(pattern: String) -> Bool { 13 | do { 14 | let regex = try NSRegularExpression(pattern: pattern) 15 | let range = NSRange(startIndex ..< endIndex, in: self) 16 | let match = regex.firstMatch(in: self, range: range) 17 | return match != nil 18 | } catch { 19 | return false 20 | } 21 | } 22 | } 23 | 24 | extension String { 25 | /// Finds the index of the matching closing bracket for a given opening bracket. 26 | /// - Parameters: 27 | /// - open: The opening bracket character. 28 | /// - close: The closing bracket character. 29 | /// - Returns: The index of the matching closing bracket if found, otherwise `nil`. 30 | /// - Complexity: O(n), where n is the length of the string. 31 | func indexForMatchingBracket( 32 | open: Character, 33 | close: Character 34 | ) -> Int? { 35 | var depth = 0 36 | for (index, char) in enumerated() { 37 | depth += (char == open) ? 1 : (char == close) ? -1 : 0 38 | if depth == 0 { 39 | return index 40 | } 41 | } 42 | return nil 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/WeakSelfCheckCore/Extension/FunctionParameterSyntax+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FunctionParameterSyntax+.swift 3 | // 4 | // 5 | // Created by p-x9 on 2024/06/14 6 | // 7 | // 8 | 9 | import Foundation 10 | import SwiftSyntax 11 | 12 | extension FunctionParameterSyntax { 13 | /// A boolean value that indicates whatever the parameter is a closure type. 14 | var isFunctionType: Bool { 15 | type.isFunctionType 16 | } 17 | 18 | /// A boolean value that indicates whatever the parameter is a optional type. 19 | var isOptionalType: Bool { 20 | type.isOptionalType 21 | } 22 | 23 | /// A boolean value that indicates whether the parameter has `@escaping` attribute. 24 | var isEscaping: Bool { 25 | guard let type = type.as(AttributedTypeSyntax.self) else { 26 | return false 27 | } 28 | let isEscaping = type.attributes.contains(where: { 29 | if case let .attribute(attribute) = $0 { 30 | return attribute.isEscaping 31 | } 32 | return false 33 | }) 34 | 35 | return isEscaping 36 | } 37 | } 38 | 39 | extension AttributeSyntax { 40 | /// A boolean value that indicates whatever the attribute is `@escaping` or not. 41 | var isEscaping: Bool { 42 | guard let identifierType = attributeName.as(IdentifierTypeSyntax.self) else { 43 | return false 44 | } 45 | return identifierType.name.tokenKind == .identifier("escaping") 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "pins" : [ 3 | { 4 | "identity" : "swift-argument-parser", 5 | "kind" : "remoteSourceControl", 6 | "location" : "https://github.com/apple/swift-argument-parser.git", 7 | "state" : { 8 | "revision" : "0fbc8848e389af3bb55c182bc19ca9d5dc2f255b", 9 | "version" : "1.4.0" 10 | } 11 | }, 12 | { 13 | "identity" : "swift-indexstore", 14 | "kind" : "remoteSourceControl", 15 | "location" : "https://github.com/kateinoigakukun/swift-indexstore.git", 16 | "state" : { 17 | "revision" : "9363af98d32247bf953855fc99d1f1f1ef803e43", 18 | "version" : "0.3.0" 19 | } 20 | }, 21 | { 22 | "identity" : "swift-source-reporter", 23 | "kind" : "remoteSourceControl", 24 | "location" : "https://github.com/p-x9/swift-source-reporter.git", 25 | "state" : { 26 | "revision" : "702db03a2b7277e89f5859dd7b260160636d1187", 27 | "version" : "0.2.0" 28 | } 29 | }, 30 | { 31 | "identity" : "swift-syntax", 32 | "kind" : "remoteSourceControl", 33 | "location" : "https://github.com/swiftlang/swift-syntax.git", 34 | "state" : { 35 | "revision" : "f99ae8aa18f0cf0d53481901f88a0991dc3bd4a2", 36 | "version" : "601.0.1" 37 | } 38 | }, 39 | { 40 | "identity" : "yams", 41 | "kind" : "remoteSourceControl", 42 | "location" : "https://github.com/jpsim/Yams.git", 43 | "state" : { 44 | "revision" : "9234124cff5e22e178988c18d8b95a8ae8007f76", 45 | "version" : "5.1.2" 46 | } 47 | } 48 | ], 49 | "version" : 2 50 | } 51 | -------------------------------------------------------------------------------- /Sources/WeakSelfCheckCore/Util/demangle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // demangle.swift 3 | // 4 | // 5 | // Created by p-x9 on 2024/06/14 6 | // 7 | // 8 | 9 | import Foundation 10 | 11 | @_silgen_name("swift_demangle") 12 | internal func _stdlib_demangleImpl( 13 | mangledName: UnsafePointer?, 14 | mangledNameLength: UInt, 15 | outputBuffer: UnsafeMutablePointer?, 16 | outputBufferSize: UnsafeMutablePointer?, 17 | flags: UInt32 18 | ) -> UnsafeMutablePointer? 19 | 20 | internal func stdlib_demangleName( 21 | _ mangledName: String 22 | ) -> String { 23 | guard !mangledName.isEmpty else { return mangledName } 24 | return mangledName.utf8CString.withUnsafeBufferPointer { mangledNameUTF8 in 25 | let demangledNamePtr = _stdlib_demangleImpl( 26 | mangledName: mangledNameUTF8.baseAddress, 27 | mangledNameLength: numericCast(mangledNameUTF8.count - 1), 28 | outputBuffer: nil, 29 | outputBufferSize: nil, 30 | flags: 0 31 | ) 32 | 33 | if let demangledNamePtr { 34 | return String(cString: demangledNamePtr) 35 | } 36 | return mangledName 37 | } 38 | } 39 | 40 | internal func stdlib_demangleName( 41 | _ mangledName: UnsafePointer 42 | ) -> UnsafePointer { 43 | 44 | let demangledNamePtr = _stdlib_demangleImpl( 45 | mangledName: mangledName, 46 | mangledNameLength: numericCast(strlen(mangledName)), 47 | outputBuffer: nil, 48 | outputBufferSize: nil, 49 | flags: 0 50 | ) 51 | if let demangledNamePtr { 52 | return .init(demangledNamePtr) 53 | } 54 | return mangledName 55 | } 56 | 57 | -------------------------------------------------------------------------------- /Sources/WeakSelfCheckCore/Extension/TypeSyntaxProtocol+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TypeSyntaxProtocol+.swift 3 | // swift-weak-self-check 4 | // 5 | // Created by p-x9 on 2024/11/11 6 | // 7 | // 8 | 9 | import Foundation 10 | import SwiftSyntax 11 | 12 | extension TypeSyntaxProtocol { 13 | /// A boolean value that indicates whatever the type is a closure type. 14 | var isFunctionType: Bool { 15 | if kind == .functionType { 16 | return true 17 | } 18 | 19 | if let type = self.as(MemberTypeSyntax.self) { 20 | if type.name.tokenKind == .identifier("Optional"), 21 | let genericArgumentClause = type.genericArgumentClause, 22 | let argument = genericArgumentClause.arguments.first { 23 | #if canImport(SwiftSyntax601) 24 | if case let .type(type) = argument.argument { 25 | return type.isFunctionType 26 | } 27 | return false 28 | #else 29 | return argument.argument.isFunctionType 30 | #endif 31 | } 32 | return type.baseType.isFunctionType 33 | } 34 | if let type = self.as(AttributedTypeSyntax.self) { 35 | return type.baseType.isFunctionType 36 | } 37 | if let type = self.as(OptionalTypeSyntax.self) { 38 | return type.wrappedType.isFunctionType 39 | } 40 | if let type = self.as(ImplicitlyUnwrappedOptionalTypeSyntax.self) { 41 | return type.wrappedType.isFunctionType 42 | } 43 | 44 | if let type = self.as(IdentifierTypeSyntax.self), 45 | type.name.tokenKind == .identifier("Optional"), 46 | let genericArgumentClause = type.genericArgumentClause, 47 | let argument = genericArgumentClause.arguments.first { 48 | #if canImport(SwiftSyntax601) 49 | if case let .type(type) = argument.argument { 50 | return type.isFunctionType 51 | } 52 | return false 53 | #else 54 | return argument.argument.isFunctionType 55 | #endif 56 | } 57 | 58 | return false 59 | } 60 | } 61 | 62 | extension TypeSyntaxProtocol { 63 | var isOptionalType: Bool { 64 | if self.as(OptionalTypeSyntax.self) != nil { 65 | return true 66 | } 67 | if self.as(ImplicitlyUnwrappedOptionalTypeSyntax.self) != nil { 68 | return true 69 | } 70 | if let type = self.as(MemberTypeSyntax.self), 71 | type.name.tokenKind == .identifier("Optional") { 72 | return true 73 | } 74 | if let type = self.as(IdentifierTypeSyntax.self), 75 | type.name.tokenKind == .identifier("Optional") { 76 | return true 77 | } 78 | return false 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Sources/WeakSelfCheckCore/Extension/IndexStoreSymbol+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IndexStoreSymbol+.swift 3 | // 4 | // 5 | // Created by p-x9 on 2024/06/14 6 | // 7 | // 8 | 9 | import Foundation 10 | import SwiftIndexStore 11 | import SwiftSyntax 12 | import SwiftSyntaxBuilder 13 | 14 | extension IndexStoreSymbol { 15 | var demangledName: String? { 16 | guard let usr else { return nil } 17 | 18 | // swift symbol 19 | if usr.hasPrefix("s:") { 20 | let index = usr.lastIndex(of: ":").map { usr.index($0, offsetBy: -1) } 21 | if let index { 22 | var symbol = String(usr[index...]) 23 | symbol.replaceSubrange(symbol.startIndex...symbol.index(symbol.startIndex, offsetBy: 1), with: "$S") 24 | return stdlib_demangleName(symbol) 25 | } 26 | } 27 | return stdlib_demangleName(usr) 28 | } 29 | } 30 | 31 | extension IndexStoreSymbol { 32 | func functionDecl(_ name: String) -> FunctionDeclSyntax? { 33 | guard language == .swift, 34 | let string = _functionDeclString(name) else { 35 | return nil 36 | } 37 | 38 | let decl: DeclSyntax = """ 39 | \(raw: string) 40 | """ 41 | 42 | let functionDecl = decl 43 | .as(FunctionDeclSyntax.self) 44 | 45 | return functionDecl 46 | } 47 | 48 | private func _functionDeclString(_ name: String) -> String? { 49 | guard var string = demangledName else { return nil } 50 | 51 | if string.starts(with: "c:") { return nil } 52 | 53 | if string.starts(with: "("), 54 | let closeIndex = string.indexForMatchingBracket(open: "(", close: ")") { 55 | let index = string.index(string.startIndex, offsetBy: closeIndex) 56 | string = String(string[index...]) 57 | } 58 | 59 | string = string.parentMemberStripped 60 | 61 | return "func " + string + " {}" 62 | } 63 | } 64 | 65 | extension String { 66 | fileprivate var parentMemberStripped: String { 67 | guard let index = firstIndex(of: "(") else { 68 | return self 69 | } 70 | var functionName = String(self[.." { depth += 1 } 78 | if current == "<" { depth -= 1 } 79 | 80 | if depth == 0 && current == "." { 81 | currentIndex = functionName.index(after: currentIndex) 82 | break 83 | } 84 | 85 | currentIndex = functionName.index(before: currentIndex) 86 | } 87 | 88 | functionName = String( 89 | functionName[currentIndex.. Bool { 19 | let visitor = _ClosureWeakSelfCheckerSyntaxVisitor( 20 | fileName: fileName, 21 | indexStore: indexStore 22 | ) 23 | visitor.walk(node) 24 | return visitor.isValid 25 | } 26 | } 27 | 28 | fileprivate final class _ClosureWeakSelfCheckerSyntaxVisitor: SyntaxVisitor { 29 | 30 | let fileName: String 31 | let indexStore: IndexStore? 32 | 33 | public private(set) var isValid: Bool = true 34 | 35 | init(fileName: String, indexStore: IndexStore?) { 36 | self.fileName = fileName 37 | self.indexStore = indexStore 38 | super.init(viewMode: .all) 39 | } 40 | 41 | override func visit(_ node: ClosureExprSyntax) -> SyntaxVisitorContinueKind { 42 | let statements = node.statements 43 | let signature = node.signature 44 | let parameterClause = signature?.parameterClause 45 | 46 | // Check if `self` is used in closure. 47 | guard SelfAccessDetector.check(statements) else { 48 | return .skipChildren 49 | } 50 | 51 | // Check if `[weak self]` or `[unowned self]` is already been set. 52 | if let capture = signature?.capture, 53 | capture.items.contains(where: { $0.isWeakSelf || $0.isUnownedSelf }) { 54 | return .skipChildren 55 | } 56 | 57 | // Check if `self` or ``self`` is included in parameter list 58 | if let parameterClause { 59 | if let items = parameterClause.as(ClosureParameterClauseSyntax.self)?.parameters, 60 | items.contains(where: { 61 | if let secondName = $0.secondName { 62 | return secondName.tokenKind.isSelf 63 | } else { 64 | return $0.firstName.tokenKind.isSelf 65 | } 66 | }) { 67 | return .skipChildren 68 | } 69 | if let items = parameterClause.as(ClosureShorthandParameterListSyntax.self), 70 | items.contains(where: { 71 | return $0.name.tokenKind.isSelf 72 | }) { 73 | return .skipChildren 74 | } 75 | } 76 | 77 | if let isInReferencetype = try? node.isInReferenceType( 78 | in: fileName, 79 | indexStore: indexStore 80 | ), 81 | !isInReferencetype { 82 | return .skipChildren 83 | } 84 | 85 | self.isValid = false 86 | 87 | return .skipChildren 88 | } 89 | } 90 | 91 | extension TokenSyntax { 92 | fileprivate var isSelf: Bool { 93 | tokenKind.isSelf 94 | } 95 | } 96 | extension TokenKind { 97 | fileprivate var isSelf: Bool { 98 | [.keyword(.`self`), .identifier("`self`")].contains(self) 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "swift-weak-self-check", 7 | platforms: [ 8 | .macOS(.v13) 9 | ], 10 | products: [ 11 | .executable( 12 | name: "weak-self-check", 13 | targets: ["weak-self-check"] 14 | ), 15 | .plugin( 16 | name: "WeakSelfCheckBuildToolPlugin", 17 | targets: ["WeakSelfCheckBuildToolPlugin"] 18 | ), 19 | .plugin( 20 | name: "WeakSelfCheckCommandPlugin", 21 | targets: ["WeakSelfCheckCommandPlugin"] 22 | ), 23 | ], 24 | dependencies: [ 25 | .package( 26 | url: "https://github.com/apple/swift-argument-parser.git", 27 | from: "1.2.0" 28 | ), 29 | .package( 30 | url: "https://github.com/swiftlang/swift-syntax.git", 31 | "509.0.0"..<"602.0.0" 32 | ), 33 | .package( 34 | url: "https://github.com/kateinoigakukun/swift-indexstore.git", 35 | from: "0.3.0" 36 | ), 37 | .package( 38 | url: "https://github.com/jpsim/Yams.git", 39 | from: "5.0.1" 40 | ), 41 | .package( 42 | url: "https://github.com/p-x9/swift-source-reporter.git", 43 | from: "0.2.0" 44 | ), 45 | ], 46 | targets: [ 47 | .executableTarget( 48 | name: "weak-self-check", 49 | dependencies: [ 50 | .product(name: "ArgumentParser", package: "swift-argument-parser"), 51 | .product(name: "Yams", package: "Yams"), 52 | .product(name: "SwiftIndexStore", package: "swift-indexstore"), 53 | "WeakSelfCheckCore" 54 | ] 55 | ), 56 | .target( 57 | name: "WeakSelfCheckCore", 58 | dependencies: [ 59 | .product(name: "SwiftSyntax", package: "swift-syntax"), 60 | .product(name: "SwiftParser", package: "swift-syntax"), 61 | .product(name: "SwiftIndexStore", package: "swift-indexstore"), 62 | .product(name: "SwiftSyntaxBuilder", package: "swift-syntax"), 63 | .product(name: "SourceReporter", package: "swift-source-reporter"), 64 | ] 65 | ), 66 | .plugin( 67 | name: "WeakSelfCheckBuildToolPlugin", 68 | capability: .buildTool(), 69 | dependencies: [ 70 | "weak-self-check" 71 | ] 72 | ), 73 | .plugin( 74 | name: "WeakSelfCheckCommandPlugin", 75 | capability: .command( 76 | intent: .custom( 77 | verb: "weak-self-check", 78 | description: "Check whether `self` is captured by weak reference in Closure." 79 | ), 80 | permissions: [] 81 | ), 82 | dependencies: [ 83 | "weak-self-check" 84 | ] 85 | ), 86 | .testTarget( 87 | name: "WeakSelfCheckCoreTests", 88 | dependencies: [ 89 | .product(name: "SwiftSyntax", package: "swift-syntax"), 90 | .product(name: "SwiftSyntaxBuilder", package: "swift-syntax"), 91 | "WeakSelfCheckCore" 92 | ] 93 | ) 94 | ] 95 | ) 96 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # swift-weak-self-check 2 | 3 | A CLI tool for `[weak self]` detection by `swift-syntax` 4 | 5 | 6 | 7 | [![Github issues](https://img.shields.io/github/issues/p-x9/swift-weak-self-check)](https://github.com/p-x9/swift-weak-self-check/issues) 8 | [![Github forks](https://img.shields.io/github/forks/p-x9/swift-weak-self-check)](https://github.com/p-x9/swift-weak-self-check/network/members) 9 | [![Github stars](https://img.shields.io/github/stars/p-x9/swift-weak-self-check)](https://github.com/p-x9/swift-weak-self-check/stargazers) 10 | [![Github top language](https://img.shields.io/github/languages/top/p-x9/swift-weak-self-check)](https://github.com/p-x9/swift-weak-self-check/) 11 | 12 | ## Usage 13 | 14 | ### CLI 15 | 16 | ```text 17 | OVERVIEW: Check whether `self` is captured by weak reference in Closure. 18 | 19 | USAGE: weak-self-check [] [--report-type ] [--quick] [--silent] [--config ] [--index-store-path ] 20 | 21 | ARGUMENTS: 22 | Path 23 | 24 | OPTIONS: 25 | --report-type 26 | Detected as `error` or `warning` (default: error) 27 | --quick Check more quicklys. (Not accurate as indexPath is 28 | not used) 29 | --silent Do not output logs 30 | --config Config (default: .swift-weak-self-check.yml) 31 | --index-store-path 32 | Path for IndexStore 33 | -h, --help Show help information. 34 | ``` 35 | 36 | ### SPM Plugin 37 | 38 | - **WeakSelfCheckBuildToolPlugin** 39 | BuildToolPlugin 40 | - **WeakSelfCheckCommandPlugin** 41 | CommandPlugin 42 | 43 | ### Configuration 44 | 45 | It is possible to customise the configuration by placing a file named `.swift-weak-self-check.yml`. 46 | 47 | Example file is available here: [swift-weak-self-check.yml](./.swift-weak-self-check.yml) 48 | 49 | ## Heuristic 50 | 51 | Detection is performed under the following conditions and a warning/error is reported. 52 | 53 | 1. All functions called within a class are subject to traversal. 54 | - If it is in an extension, a decision is made as to whether it is a class or not based on the index-store information. 55 | - In quick mode, all functions in the extension are checked, regardless of whether they are classes or not. 56 | 57 | 2. Any closure present as an argument of the function is checked. 58 | - If the closure type is specified with a type defined by typealias, it is missed. 59 | 60 | 3. If the function is included in a whitelist in the config, it is skipped. 61 | 62 | 4. Check that `self` is used in the closure without `[weak self]` or `[unowned self]`. 63 | 64 | 5. Check that `@escaping` attribute is attached to the closure type of the function being called. 65 | - Information from the index-store is used. (So, not checked in quick mode). 66 | - It is not checked for c and objc functions. 67 | - If multiple closures are present in the function argument without labels, this check is not performed. 68 | 69 | 6. If the closure type is of type `Optional`, the warning is applicable even if the `@escaping` attribute is not attached. 70 | - In the case of optional closure types, there are cases where circular references are produced even when `@escaping` is not attached. 71 | 72 | 7. warning/error is reported 73 | 74 | ## License 75 | 76 | swift-weak-self-check is released under the MIT License. See [LICENSE](./LICENSE) 77 | -------------------------------------------------------------------------------- /Plugins/WeakSelfCheckBuildToolPlugin/plugin.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WeakSelfCheckBuildToolPlugin.swift 3 | // 4 | // 5 | // Created by p-x9 on 2024/06/08 6 | // 7 | // 8 | 9 | import Foundation 10 | import PackagePlugin 11 | 12 | @main 13 | struct WeakSelfCheckBuildToolPlugin: BuildToolPlugin { 14 | func createBuildCommands(context: PackagePlugin.PluginContext, target: PackagePlugin.Target) async throws -> [PackagePlugin.Command] { 15 | createBuildCommands( 16 | packageDirectory: context.package.directoryURL, 17 | workingDirectory: context.pluginWorkDirectoryURL, 18 | tool: try context.tool(named: "weak-self-check") 19 | ) 20 | } 21 | 22 | private func createBuildCommands( 23 | packageDirectory: URL, 24 | workingDirectory: URL, 25 | tool: PluginContext.Tool 26 | ) -> [Command] { 27 | let configuration = packageDirectory.firstConfigurationFileInParentDirectories() 28 | 29 | var arguments = [ 30 | packageDirectory.path 31 | ] 32 | 33 | if let configuration { 34 | arguments += [ 35 | "--config", configuration.path 36 | ] 37 | } 38 | 39 | return [ 40 | .buildCommand( 41 | displayName: "WeakSelfCheckBuildToolPlugin", 42 | executable: tool.url, 43 | arguments: arguments 44 | ) 45 | ] 46 | } 47 | } 48 | 49 | #if canImport(XcodeProjectPlugin) 50 | import XcodeProjectPlugin 51 | 52 | extension WeakSelfCheckBuildToolPlugin: XcodeBuildToolPlugin { 53 | func createBuildCommands(context: XcodePluginContext, target: XcodeTarget) throws -> [Command] { 54 | return createBuildCommands( 55 | packageDirectory: context.xcodeProject.directoryURL, 56 | workingDirectory: context.pluginWorkDirectoryURL, 57 | tool: try context.tool(named: "weak-self-check") 58 | ) 59 | } 60 | } 61 | #endif 62 | 63 | // ref: https://github.com/realm/SwiftLint/blob/main/Plugins/SwiftLintPlugin/Path%2BHelpers.swift 64 | extension URL { 65 | func firstConfigurationFileInParentDirectories() -> URL? { 66 | let defaultConfigurationFileNames = [ 67 | ".swift-weak-self-check.yml" 68 | ] 69 | let proposedDirectories = sequence( 70 | first: self, 71 | next: { path in 72 | guard path.pathComponents.count > 1 else { 73 | // Check we're not at the root of this filesystem, as `removingLastComponent()` 74 | // will continually return the root from itself. 75 | return nil 76 | } 77 | 78 | return path.deletingLastPathComponent() 79 | } 80 | ) 81 | 82 | for proposedDirectory in proposedDirectories { 83 | for fileName in defaultConfigurationFileNames { 84 | let potentialConfigurationFile = proposedDirectory.appending(path: fileName) 85 | if potentialConfigurationFile.isAccessible() { 86 | return potentialConfigurationFile 87 | } 88 | } 89 | } 90 | return nil 91 | } 92 | 93 | /// Safe way to check if the file is accessible from within the current process sandbox. 94 | private func isAccessible() -> Bool { 95 | let result = path.withCString { pointer in 96 | access(pointer, R_OK) 97 | } 98 | 99 | return result == 0 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /Plugins/WeakSelfCheckCommandPlugin/plugin.swift: -------------------------------------------------------------------------------- 1 | // 2 | // plugin.swift 3 | // 4 | // 5 | // Created by p-x9 on 2024/06/07 6 | // 7 | // 8 | 9 | import Foundation 10 | import PackagePlugin 11 | 12 | @main 13 | struct WeakSelfCheckCommandPlugin: CommandPlugin { 14 | func performCommand(context: PackagePlugin.PluginContext, arguments: [String]) async throws { 15 | try performCommand( 16 | packageDirectory: context.package.directoryURL, 17 | tool: try context.tool(named: "weak-self-check"), 18 | arguments: arguments 19 | ) 20 | } 21 | 22 | private func performCommand( 23 | packageDirectory: URL, 24 | tool: PluginContext.Tool, 25 | arguments: [String] 26 | ) throws { 27 | var argumentExtractor = ArgumentExtractor(arguments) 28 | let reportType = argumentExtractor.extractOption(named: "report-type").first ?? "error" 29 | let silent = argumentExtractor.extractFlag(named: "silent") 30 | let config = argumentExtractor.extractOption(named: "config").first 31 | ?? packageDirectory.firstConfigurationFileInParentDirectories()?.path ?? "" 32 | let indexStorePath = argumentExtractor.extractOption(named: "index-store-path").first 33 | let _ = argumentExtractor.extractOption(named: "target") 34 | let path = argumentExtractor.remainingArguments.first ?? packageDirectory.path 35 | 36 | let process = Process() 37 | process.launchPath = tool.url.path 38 | process.arguments = [ 39 | path, 40 | "--config", 41 | config, 42 | "--report-type", 43 | reportType 44 | ] 45 | 46 | if (silent != 0) { 47 | process.arguments?.append("--silent") 48 | } 49 | 50 | if let indexStorePath { 51 | process.arguments? += [ 52 | "--index-store-path", 53 | indexStorePath 54 | ] 55 | } 56 | 57 | try process.run() 58 | process.waitUntilExit() 59 | } 60 | } 61 | 62 | #if canImport(XcodeProjectPlugin) 63 | import XcodeProjectPlugin 64 | 65 | extension WeakSelfCheckCommandPlugin: XcodeCommandPlugin { 66 | func performCommand(context: XcodeProjectPlugin.XcodePluginContext, arguments: [String]) throws { 67 | try performCommand( 68 | packageDirectory: context.xcodeProject.directoryURL, 69 | tool: try context.tool(named: "weak-self-check"), 70 | arguments: arguments 71 | ) 72 | } 73 | } 74 | #endif 75 | 76 | // ref: https://github.com/realm/SwiftLint/blob/main/Plugins/SwiftLintPlugin/Path%2BHelpers.swift 77 | extension URL { 78 | func firstConfigurationFileInParentDirectories() -> URL? { 79 | let defaultConfigurationFileNames = [ 80 | ".swift-weak-self-check.yml" 81 | ] 82 | let proposedDirectories = sequence( 83 | first: self, 84 | next: { path in 85 | guard path.pathComponents.count > 1 else { 86 | // Check we're not at the root of this filesystem, as `removingLastComponent()` 87 | // will continually return the root from itself. 88 | return nil 89 | } 90 | 91 | return path.deletingLastPathComponent() 92 | } 93 | ) 94 | 95 | for proposedDirectory in proposedDirectories { 96 | for fileName in defaultConfigurationFileNames { 97 | let potentialConfigurationFile = proposedDirectory.appending(path: fileName) 98 | if potentialConfigurationFile.isAccessible() { 99 | return potentialConfigurationFile 100 | } 101 | } 102 | } 103 | return nil 104 | } 105 | 106 | /// Safe way to check if the file is accessible from within the current process sandbox. 107 | private func isAccessible() -> Bool { 108 | let result = path.withCString { pointer in 109 | access(pointer, R_OK) 110 | } 111 | 112 | return result == 0 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /Sources/weak-self-check/main.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import ArgumentParser 3 | import Yams 4 | import SwiftIndexStore 5 | import SourceReporter 6 | import WeakSelfCheckCore 7 | 8 | struct weak_self_check: ParsableCommand { 9 | static let configuration: CommandConfiguration = .init( 10 | commandName: "weak-self-check", 11 | abstract: "Check whether `self` is captured by weak reference in Closure.", 12 | shouldDisplay: true, 13 | helpNames: [.long, .short] 14 | ) 15 | 16 | @Argument( 17 | help: "Path", 18 | completion: .directory 19 | ) 20 | var path: String? 21 | 22 | @Option(help: "Detected as `error` or `warning` (default: error)") 23 | var reportType: ReportType? 24 | 25 | @Flag( 26 | name: .customLong("quick"), 27 | help: "Check more quicklys. (Not accurate as indexPath is not used)" 28 | ) 29 | var quick: Bool = false 30 | 31 | @Flag(name: .customLong("silent"), help: "Do not output logs") 32 | var silent: Bool = false 33 | 34 | @Option( 35 | help: "Config", 36 | completion: .file(extensions: ["yml", "yaml"]) 37 | ) 38 | var config: String = ".swift-weak-self-check.yml" 39 | 40 | @Option( 41 | help: "Path for IndexStore", 42 | completion: .directory 43 | ) 44 | var indexStorePath: String? 45 | 46 | var whiteList: [WhiteListElement] = [] 47 | var excludedFiles: [String] = [] 48 | 49 | lazy var indexStore: IndexStore? = { 50 | if let indexStorePath = indexStorePath ?? environmentIndexStorePath, 51 | FileManager.default.fileExists(atPath: indexStorePath) { 52 | let url = URL(fileURLWithPath: indexStorePath) 53 | return try? .open(store: url, lib: .open()) 54 | } else { 55 | return nil 56 | } 57 | }() 58 | 59 | mutating func run() throws { 60 | try readConfig() 61 | 62 | let path = self.path ?? FileManager.default.currentDirectoryPath 63 | let url = URL(fileURLWithPath: path) 64 | 65 | if FileManager.default.isDirectory(url) { 66 | try check(forDirectory: url) 67 | } else { 68 | try check(forFile: url) 69 | } 70 | } 71 | } 72 | 73 | extension weak_self_check { 74 | private mutating func check(forDirectory url: URL) throws { 75 | let fileManager: FileManager = .default 76 | 77 | let contents = try fileManager.contentsOfDirectory( 78 | at: url, 79 | includingPropertiesForKeys: nil 80 | ) 81 | try? contents 82 | .forEach { 83 | if $0.pathExtension == "swift" { 84 | try check(forFile: $0) 85 | } else if fileManager.isDirectory($0) { 86 | try check(forDirectory: $0) 87 | } 88 | } 89 | } 90 | 91 | private mutating func check(forFile url: URL) throws { 92 | guard url.pathExtension == "swift" else { return } 93 | guard !excludedFiles.contains(where: { url.path.matches(pattern: $0) }) else { 94 | return 95 | } 96 | if !silent { 97 | print("[weak self check] checking: \(url.relativePath)") 98 | } 99 | 100 | let checker = WeakSelfChecker( 101 | fileName: url.path, 102 | reportType: reportType ?? .error, 103 | whiteList: whiteList, 104 | indexStore: quick ? nil : indexStore 105 | ) 106 | try? checker.diagnose() 107 | } 108 | } 109 | 110 | extension weak_self_check { 111 | private mutating func readConfig() throws { 112 | guard FileManager.default.fileExists(atPath: config) else { 113 | return 114 | } 115 | let url = URL(fileURLWithPath: config) 116 | let decoder = YAMLDecoder() 117 | 118 | let data = try Data(contentsOf: url) 119 | let config = try decoder.decode(Config.self, from: data) 120 | 121 | self.whiteList = config.whiteList ?? [] 122 | self.excludedFiles = config.excludedFiles ?? [] 123 | 124 | if let slient = config.slent, slient { 125 | self.silent = true 126 | } 127 | if let quick = config.quick, quick { 128 | self.quick = true 129 | } 130 | if reportType == nil { 131 | self.reportType = config.reportType 132 | } 133 | } 134 | } 135 | 136 | extension weak_self_check { 137 | var environmentIndexStorePath: String? { 138 | let environment = ProcessInfo.processInfo.environment 139 | guard let buildDir = environment["BUILD_DIR"] else { return nil } 140 | let url = URL(fileURLWithPath: buildDir) 141 | return url 142 | .deletingLastPathComponent() 143 | .deletingLastPathComponent() 144 | .appending(path: "Index.noindex/DataStore/") 145 | .path() 146 | } 147 | } 148 | 149 | weak_self_check.main() 150 | -------------------------------------------------------------------------------- /Sources/WeakSelfCheckCore/Extension/Syntax+.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Syntax+.swift 3 | // 4 | // 5 | // Created by p-x9 on 2024/06/05 6 | // 7 | // 8 | 9 | import Foundation 10 | import SwiftSyntax 11 | import SwiftIndexStore 12 | 13 | extension SyntaxProtocol { 14 | /// A Boolean value that indicates whether it is a syntax that exists inside the reference type. 15 | /// 16 | /// If it is `nil`, it means that it is inside the extension or protocol and cannot be determined. 17 | fileprivate var isInReferencetype: Bool? { 18 | var current: (any SyntaxProtocol)? = self 19 | while current?.hasParent ?? false { 20 | guard let _current = current, 21 | let parent = _current.parent else { 22 | break 23 | } 24 | 25 | defer { 26 | current = parent 27 | } 28 | 29 | switch parent.kind { 30 | case .classDecl: fallthrough 31 | case .actorDecl: 32 | return true 33 | case .structDecl: fallthrough 34 | case .enumDecl: 35 | return false 36 | case .extensionDecl: fallthrough 37 | case .protocolDecl: 38 | return nil 39 | default: 40 | continue 41 | } 42 | } 43 | 44 | return nil 45 | } 46 | } 47 | 48 | extension SyntaxProtocol { 49 | /// A Boolean value that indicates whether it is a syntax that exists inside the reference type. 50 | /// 51 | /// Based on syntax and indexStore information to determine. 52 | /// If it is `nil`, it means that it is inside the extension or protocol and cannot be determined. 53 | /// 54 | /// - Parameters: 55 | /// - fileName: Path of the file containing this syntax 56 | /// - indexStore: Index Store Path 57 | /// - Returns: Whether this syntax is internal to the reference type. 58 | func isInReferenceType(in fileName: String, indexStore: IndexStore?) throws -> Bool? { 59 | guard let indexStore else { 60 | return isInReferencetype 61 | } 62 | 63 | // If known from syntax infos 64 | if let isInReferencetype { 65 | return isInReferencetype 66 | } 67 | 68 | guard let _containedTypeDecl = containedTypeDecl, 69 | let containedTypeName = _containedTypeDecl.declTypeName else { 70 | return nil 71 | } 72 | 73 | let location = containedTypeName.startLocation( 74 | converter: .init( 75 | fileName: fileName, 76 | tree: containedTypeName.root 77 | ) 78 | ) 79 | 80 | var occurrence: IndexStoreOccurrence? 81 | 82 | try indexStore.forEachUnits(includeSystem: false) { unit in 83 | try indexStore.forEachRecordDependencies(for: unit) { dependency in 84 | guard case let .record(record) = dependency, 85 | record.filePath == fileName else { 86 | return true 87 | } 88 | 89 | try indexStore.forEachOccurrences(for: record) { 90 | let l = $0.location 91 | if !l.isSystem, 92 | l.line == location.line && l.column == location.column, 93 | $0.roles.contains([.reference, .extendedBy]) { 94 | occurrence = $0 95 | return false 96 | } 97 | return true 98 | } // forEachOccurrences 99 | 100 | return false 101 | } // forEachRecordDependencies 102 | 103 | return occurrence == nil 104 | } // forEachUnits 105 | 106 | guard let occurrence else { return nil } 107 | 108 | let kind = occurrence.symbol.kind 109 | let name = occurrence.symbol.name 110 | switch kind { 111 | case .class: return true 112 | case .struct: return false 113 | case .enum where name != "Optional": return false 114 | case .protocol: return nil 115 | default: return nil 116 | } 117 | } 118 | } 119 | 120 | extension SyntaxProtocol { 121 | var containedTypeDecl: DeclSyntax? { 122 | var current: (any SyntaxProtocol)? = self 123 | while current?.hasParent ?? false { 124 | guard let _current = current, 125 | let parent = _current.parent else { 126 | break 127 | } 128 | 129 | defer { 130 | current = parent 131 | } 132 | 133 | switch parent.kind { 134 | case .classDecl: fallthrough 135 | case .actorDecl: fallthrough 136 | case .structDecl: fallthrough 137 | case .enumDecl: fallthrough 138 | case .extensionDecl: fallthrough 139 | case .protocolDecl: 140 | return DeclSyntax(parent) 141 | default: 142 | continue 143 | } 144 | } 145 | 146 | return nil 147 | } 148 | } 149 | 150 | extension DeclSyntax { 151 | var declTypeName: SyntaxProtocol? { 152 | if let decl = self.as(ClassDeclSyntax.self) { 153 | return decl.name 154 | } 155 | 156 | if let decl = self.as(ActorDeclSyntax.self) { 157 | return decl.name 158 | } 159 | 160 | if let decl = self.as(StructDeclSyntax.self) { 161 | return decl.name 162 | } 163 | 164 | if let decl = self.as(EnumDeclSyntax.self) { 165 | return decl.name 166 | } 167 | 168 | if let decl = self.as(ProtocolDeclSyntax.self) { 169 | return decl.name 170 | } 171 | 172 | if let decl = self.as(ExtensionDeclSyntax.self) { 173 | let extendedType = decl.extendedType 174 | if let member = extendedType.as(MemberTypeSyntax.self) { 175 | return member.name 176 | } 177 | return decl.extendedType 178 | } 179 | 180 | return nil 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /Tests/WeakSelfCheckCoreTests/WeakSelfCheckCoreTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WeakSelfCheckCoreTests.swift 3 | // swift-weak-self-check 4 | // 5 | // Created by p-x9 on 2025/04/20 6 | // 7 | // 8 | 9 | import XCTest 10 | import SwiftSyntax 11 | import SwiftSyntaxBuilder 12 | import SourceReporter 13 | @testable import WeakSelfCheckCore 14 | 15 | final class WeakSelfCheckCoreTests: XCTestCase { 16 | let defaultMessage = "Use `[weak self]` to avoid memory leaks" 17 | } 18 | 19 | extension WeakSelfCheckCoreTests { 20 | func testMethodArgumentInClass() throws { 21 | let source = """ 22 | class MyViewController: UIViewController { 23 | func fetchData() { 24 | DispatchQueue.global().async { 25 | print(self.view) 26 | } 27 | } 28 | } 29 | """ 30 | try checkReports( 31 | source: source, 32 | expectedReports: [ 33 | .init( 34 | position: .init( 35 | file: #fileID, 36 | line: 3, 37 | column: 38 38 | ), 39 | type: .error, 40 | content: defaultMessage 41 | ) 42 | ] 43 | ) 44 | 45 | let source2 = """ 46 | class MyViewController: UIViewController { 47 | func fetchData() { 48 | DispatchQueue.global().async { [weak self] in 49 | print(self!.view) 50 | } 51 | } 52 | } 53 | """ 54 | try checkReports( 55 | source: source2, 56 | expectedReports: [] 57 | ) 58 | 59 | let source3 = """ 60 | class MyViewController: UIViewController { 61 | func fetchData() { 62 | DispatchQueue.global().async { [unowned self] in 63 | print(self!.view) 64 | } 65 | } 66 | } 67 | """ 68 | try checkReports( 69 | source: source3, 70 | expectedReports: [] 71 | ) 72 | 73 | let source4 = """ 74 | enum NameSpace { 75 | class MyViewController: UIViewController { 76 | func fetchData() { 77 | DispatchQueue.global().async { 78 | print(self!.view) 79 | } 80 | } 81 | } 82 | } 83 | """ 84 | try checkReports( 85 | source: source4, 86 | expectedReports: [ 87 | .init( 88 | position: .init( 89 | file: #fileID, 90 | line: 4, 91 | column: 42 92 | ), 93 | type: .error, 94 | content: defaultMessage 95 | ) 96 | ] 97 | ) 98 | } 99 | 100 | func testMethodArgumentInStruct() throws { 101 | let source = """ 102 | struct MyItem { 103 | func output() { 104 | DispatchQueue.global().async { 105 | print(self!.view) 106 | } 107 | } 108 | } 109 | """ 110 | try checkReports( 111 | source: source, 112 | expectedReports: [] 113 | ) 114 | 115 | let source2 = """ 116 | class MyClass { 117 | struct MyItem { 118 | func output() { 119 | DispatchQueue.global().async { 120 | print(self!.view) 121 | } 122 | } 123 | } 124 | } 125 | """ 126 | try checkReports( 127 | source: source2, 128 | expectedReports: [] 129 | ) 130 | } 131 | } 132 | 133 | extension WeakSelfCheckCoreTests { 134 | func testMethodArgumentWhiteList() throws { 135 | let source = """ 136 | class MyViewController: UIViewController { 137 | func fetchData() { 138 | DispatchQueue.global().async { 139 | print(self.view) 140 | } 141 | } 142 | } 143 | """ 144 | try checkReports( 145 | source: source, 146 | expectedReports: [], 147 | whiteList: [ 148 | .init( 149 | parentPattern: "DispatchQueue.*", 150 | functionName: "^(async|sync).*" 151 | ) 152 | ] 153 | ) 154 | 155 | try checkReports( 156 | source: source, 157 | expectedReports: [ 158 | .init( 159 | position: .init( 160 | file: #fileID, 161 | line: 3, 162 | column: 38 163 | ), 164 | type: .error, 165 | content: defaultMessage 166 | ) 167 | ], 168 | whiteList: [ 169 | .init( 170 | parentPattern: "DispatchQueue.main*", 171 | functionName: "^(async|sync).*" 172 | ) 173 | ] 174 | ) 175 | } 176 | } 177 | 178 | extension WeakSelfCheckCoreTests { 179 | fileprivate func checkReports( 180 | source: String, 181 | expectedReports: [Report] = [], 182 | whiteList: [WhiteListElement] = [] 183 | ) throws { 184 | var reports: [Report] = expectedReports 185 | let reporter: CallbackReporter = .init { report in 186 | XCTAssert( 187 | reports.contains(report), 188 | "Unexpected report: \(report)" 189 | ) 190 | if let index = reports.firstIndex(of: report) { 191 | reports.remove(at: index) 192 | } 193 | } 194 | let checker = WeakSelfChecker( 195 | fileName: #fileID, 196 | reporter: reporter, 197 | whiteList: whiteList 198 | ) 199 | 200 | try checker.diagnose(source: source) 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /Sources/WeakSelfCheckCore/WeakSelfChecker.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WeakSelfChecker.swift 3 | // 4 | // 5 | // Created by p-x9 on 2024/06/04 6 | // 7 | // 8 | 9 | import Foundation 10 | import SwiftParser 11 | import SwiftSyntax 12 | import SwiftIndexStore 13 | import SourceReporter 14 | 15 | public final class WeakSelfChecker: SyntaxVisitor { 16 | public let fileName: String 17 | public let reportType: ReportType 18 | public let reporter: any ReporterProtocol 19 | public let whiteList: [WhiteListElement] 20 | public let indexStore: IndexStore? 21 | 22 | public init( 23 | fileName: String, 24 | reportType: ReportType = .error, 25 | reporter: any ReporterProtocol = XcodeReporter(), 26 | whiteList: [WhiteListElement] = [], 27 | indexStore: IndexStore? = nil 28 | ) { 29 | self.fileName = fileName 30 | self.reportType = reportType 31 | self.reporter = reporter 32 | self.whiteList = whiteList 33 | self.indexStore = indexStore 34 | 35 | super.init(viewMode: .all) 36 | } 37 | 38 | public override func visit(_ node: FunctionCallExprSyntax) -> SyntaxVisitorContinueKind { 39 | guard !checkIfContainsInWhiteList(node) else { 40 | return .visitChildren 41 | } 42 | 43 | func hasEscaping( 44 | for label: String?, 45 | isTrailing: Bool = false 46 | ) -> Bool { 47 | guard let funcDecl = try? functionDecl(for: node) else { 48 | return true 49 | } 50 | let parameters = funcDecl.signature.parameterClause.parameters 51 | 52 | var parameter: FunctionParameterSyntax? 53 | 54 | if let label { 55 | parameter = parameters.first(where: { 56 | $0.secondName?.trimmedDescription == label || 57 | $0.firstName.trimmedDescription == label 58 | }) 59 | } else if parameters.count(where: \.isFunctionType) == 1 { 60 | parameter = parameters.first(where: { 61 | $0.isFunctionType 62 | }) 63 | } 64 | // TODO: Handle function which has multiple closures without label 65 | 66 | guard let parameter else { return true } 67 | 68 | if parameter.isFunctionType, 69 | !parameter.type.isOptionalType, 70 | !parameter.isEscaping { 71 | return false 72 | } 73 | 74 | return true 75 | } 76 | 77 | for argument in node.arguments { 78 | guard let closure = argument.expression.as(ClosureExprSyntax.self) else { 79 | continue 80 | } 81 | if !ClosureWeakSelfChecker.check(closure, in: fileName, indexStore: indexStore), 82 | hasEscaping(for: argument.label?.trimmedDescription) { 83 | report(for: closure) 84 | } 85 | } 86 | 87 | // Check trailing closure 88 | if let trailingClosure = node.trailingClosure, 89 | !ClosureWeakSelfChecker.check(trailingClosure, in: fileName, indexStore: indexStore), 90 | hasEscaping(for: nil, isTrailing: true) { 91 | report(for: trailingClosure) 92 | } 93 | 94 | // Check additional trailing closures 95 | for closure in node.additionalTrailingClosures { 96 | if !ClosureWeakSelfChecker.check(closure.closure, in: fileName, indexStore: indexStore), 97 | hasEscaping(for: closure.label.trimmedDescription, isTrailing: true) { 98 | report(for: closure.closure) 99 | } 100 | } 101 | 102 | return .visitChildren 103 | } 104 | 105 | public func diagnose() throws { 106 | let source = try String(contentsOfFile: fileName) 107 | try diagnose(source: source) 108 | } 109 | 110 | internal func diagnose(source: String) throws { 111 | let syntax: SourceFileSyntax = Parser.parse(source: source) 112 | self.walk(syntax) 113 | } 114 | } 115 | 116 | extension WeakSelfChecker { 117 | private func report(for closure: ClosureExprSyntax) { 118 | let location = closure.startLocation( 119 | converter: .init( 120 | fileName: fileName, 121 | tree: closure.root 122 | ) 123 | ) 124 | reporter.report( 125 | file: fileName, 126 | line: location.line, 127 | column: location.column, 128 | type: reportType, 129 | content: "Use `[weak self]` to avoid memory leaks" 130 | ) 131 | } 132 | } 133 | 134 | extension WeakSelfChecker { 135 | private func checkIfContainsInWhiteList(_ node: FunctionCallExprSyntax) -> Bool { 136 | guard !whiteList.isEmpty else { 137 | return false 138 | } 139 | 140 | let calledExpression = node.calledExpression 141 | 142 | if let function = calledExpression.as(DeclReferenceExprSyntax.self) { 143 | return !whiteList 144 | .lazy 145 | .filter({ $0.parentPattern == nil }) 146 | .filter({ function.trimmed.description.matches(pattern: $0.functionName) }) 147 | .isEmpty 148 | } 149 | 150 | if let memberAccess = calledExpression.as(MemberAccessExprSyntax.self) { 151 | var names = memberAccess.chainedMemberNames 152 | 153 | guard let function = names.popLast() else { return false } 154 | let parent = names.joined(separator: ".") 155 | 156 | return !whiteList 157 | .lazy 158 | .filter({ 159 | parent.matches(pattern: $0.parentPattern ?? "") && function.matches(pattern: $0.functionName) 160 | }) 161 | .isEmpty 162 | } 163 | 164 | return false 165 | } 166 | } 167 | 168 | extension WeakSelfChecker { 169 | private func functionDecl(for callExpr: FunctionCallExprSyntax) throws -> FunctionDeclSyntax? { 170 | guard let occurrence = try functionCallOccurrence(for: callExpr) else { 171 | return nil 172 | } 173 | 174 | var calledExpression = callExpr.calledExpression 175 | if let member = calledExpression.as(MemberAccessExprSyntax.self) { 176 | calledExpression = ExprSyntax(member.declName) 177 | } 178 | return occurrence.symbol.functionDecl( 179 | calledExpression.trimmedDescription 180 | ) 181 | } 182 | 183 | private func functionCallOccurrence(for callExpr: FunctionCallExprSyntax) throws -> IndexStoreOccurrence? { 184 | guard let indexStore else { return nil } 185 | 186 | var calledExpression = callExpr.calledExpression 187 | 188 | if let member = calledExpression.as(MemberAccessExprSyntax.self) { 189 | calledExpression = ExprSyntax(member.declName) 190 | } 191 | 192 | let location = calledExpression.startLocation( 193 | converter: .init( 194 | fileName: fileName, 195 | tree: calledExpression.root 196 | ) 197 | ) 198 | 199 | var occurrence: IndexStoreOccurrence? 200 | 201 | try indexStore.forEachUnits(includeSystem: false) { unit in 202 | let mainFilePath = try indexStore.mainFilePath(for: unit) 203 | guard mainFilePath == fileName else { return true } 204 | 205 | try indexStore.forEachRecordDependencies(for: unit) { dependency in 206 | guard case let .record(record) = dependency, 207 | record.filePath == fileName else { 208 | return true 209 | } 210 | 211 | try indexStore.forEachOccurrences(for: record) { 212 | let l = $0.location 213 | if !l.isSystem, 214 | l.line == location.line && l.column == location.column, 215 | $0.roles.contains([.reference, .call]), 216 | [.instanceMethod, .classMethod, .staticMethod, .constructor, .function, .conversionFunction].contains($0.symbol.kind) { 217 | occurrence = $0 218 | return false 219 | } 220 | return true 221 | } // forEachOccurrences 222 | 223 | return false 224 | } // forEachRecordDependencies 225 | 226 | return occurrence == nil 227 | } // forEachUnits 228 | 229 | return occurrence 230 | } 231 | } 232 | --------------------------------------------------------------------------------