├── .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 | [](https://github.com/p-x9/swift-weak-self-check/issues)
8 | [](https://github.com/p-x9/swift-weak-self-check/network/members)
9 | [](https://github.com/p-x9/swift-weak-self-check/stargazers)
10 | [](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 |
--------------------------------------------------------------------------------