Pecker:自动检测项目中不用的代码

6,389 阅读7分钟

先放上项目的地址Pecker,觉得不错的不妨点点Star。

背景

最近在折腾编译相关的,然后就想能不能写一个检测项目中不用代码的工具,毕竟这也是比较常见的需求,但这并不容易。想了两天并没有太好的思路,因为Swift的语法是很复杂的,包括Protocol和范型,如果自己Parse源代码,然后查找哪些地方使用到它,这绝对是个大工程,想想都可怕。

正好最近看了看sourcekit-lsp,突然就来了思路,下面我会详细的讲一讲。

sourcekit-lsp

SourceKit-LSP is an implementation of the Language Server Protocol (LSP) for Swift and C-based languages. It provides features like code-completion and jump-to-definition to editors that support LSP. SourceKit-LSP is built on top of sourcekitd and clangd for high-fidelity language support, and provides a powerful source code index as well as cross-language support. SourceKit-LSP supports projects that use the Swift Package Manager.

sourcekit-lsp基于Swift和C语言的 Language Server Protocol (LSP) 实现,它提供了代码自动补全和定义跳转。

按照官方的定义,“The Language Server Protocol (LSP) defines the protocol used between an editor or IDE and a language server that provides language features like auto complete, go to definition, find all references etc.(语言服务器协议是一种被用于编辑器或集成开发环境 与 支持比如自动补全,定义跳转,查找所有引用等语言特性的语言服务器之间的一种协议)”。

这样如果你想让某个IDE支持Swift,就只需要集成sourcekit-lsp即可。比如下面这个Xcode提供的功能Jump to Definition或者Find Call Hierarchy等就是依赖这个原理,你个可以通过sourcekit-lsp让其他IDE实现这个功能。

屏幕快照 2019-12-03 下午5.53.22.png

然后我看了sourcekit-lsp的源码,发现其中的核心是依赖的一个库IndexStoreDB,这个就是我们需要的。

IndexStoreDB

IndexStoreDB is a source code indexing library. It provides a composable and efficient query API for looking up source code symbols, symbol occurrences, and relations. IndexStoreDB uses the libIndexStore library, which lives in swift-clang, for reading raw index data. Raw index data can be produced by compilers such as Clang and Swift using the -index-store-path option. IndexStoreDB enables efficiently querying this data by maintaining acceleration tables in a key-value database built with LMDB.

IndexStoreDB是源代码索引库。 它提供了可组合且高效的查询API,用于查找源代码符号,符号出现和关系。IndexStoreDB使用存在于swift-clang中的libIndexStore库读取原始索引数据。 原始索引数据可以由Clang和Swift等编译器使用-index-store-path选项生成。

swiftc -index-store-path index -index-file

实现思路

当时想过集成swift-llbuild编译项目生成Index,但是这就复杂了一些,而且如果是大项目的话生成Index需要一点时间,这样就不太友好。

屏幕快照 2019-12-03 下午6.11.54.png

想必大家对这个比较熟悉,用Xcode打开项目之后就能看到这个,这个就是Xcode在自动生成Index. 我发现生成的Index是存在DerivedData中的。

屏幕快照 2019-12-03 下午6.15.41.png

到这里思路就清晰了,步骤如下:

1. 找到项目中所有的类和方法等(SwiftSyntax)

2. 在DerivedData找到项目的Index,初始化IndexStoreDB

3. 通过IndexStoreDB查找符号,查看关系,是否有引用,确定是否被使用

4. 显示Warning

结构图如下:

屏幕快照 2019-12-04 下午2.01.45.png

例子

现在我们看一个例子:

屏幕快照 2019-12-03 下午6.38.53.png

然后在Index中的类TestObject和方法gogogo符号

/Users/ming/Desktop/Testttt/Testttt/TestObject.swift:11:7 | TestObject | class | s:7Testttt10TestObjectC | [def|canon]

属性
目录 /Users/ming/Desktop/Testttt/Testttt/TestObject.swift
11
7
符号名 TestObject
USR s:7Testttt10TestObjectC
关系 [def|canon]
/Users/ming/Desktop/Testttt/Testttt/TestObject.swift:13:10 | gogogo(_:name:) | instanceMethod | s:7Testttt10TestObjectC6gogogo_4nameyx_SStlF | [def|dyn|childOf|canon]
	[childOf] | s:7Testttt10TestObjectC
[def|dyn|childOf|canon]
属性
目录 /Users/ming/Desktop/Testttt/Testttt/TestObject.swift
13
10
符号名 gogogo(_:name:)
USR s:7Testttt10TestObjectC6gogogo_4nameyx_SStlF
关系 [def|dyn|....

再来通过TestObject符号的USR s:7Testttt10TestObjectC查看符号在项目在项目中所有出现的地方,方法没有特别的地方就不放了。

/Users/ming/Desktop/Testttt/Testttt/TestObject.swift:11:7 | TestObject | class | s:7Testttt10TestObjectC | [def|canon]
[def|canon]
/Users/ming/Desktop/Testttt/Testttt/TestObject.swift:18:11 | TestObject | class | s:7Testttt10TestObjectC | [ref]
[ref]
/Users/ming/Desktop/Testttt/Testttt/TestObject.swift:23:18 | TestObject | class | s:7Testttt10TestObjectC | [ref|contBy]
	[contBy] | s:7Testttt4testyyF
[ref|contBy]

我们看到:

  1. TestObject的符号名就是TestObject,在项目中一个地方被def定义,两个地方被ref引用,和源代码中情况一致,这里就有问题了,就是extension也是算作引用,但是我们需要通过这个判断符号是否被使用,显然extension不能算作是被使用,所以我们在使用SyntaxVisitor的时候需要把extension也记下来,然后和这里的ref通过位置进行比较,如果在收集的extension集合中发现了,那这次的出现就不能当做引用。
  2. 确定方法的符号名,gogogo<T>(_ t: T, name: String)这样的方法符号名gogogo(_:name:),所以在通过SyntaxVisitor收集的时候要按照这个规则生成符号名。
  3. 需要设置白名单,比如AppDelegateSceneDelegate等,按照上面规则,这些是会检测为未被使用的代码,需要过滤掉,这个我暂时是写死的,之后考虑像SwiftLint一样通过.yml文件开放出来让使用者自己配置。

找到项目中所有的类和方法等(SwiftSyntax)

这就是我们需要通过SwiftSyntax收集的数据结构。

/// The kind of source code, we only check the follow kind
public enum SourceKind {
    case `class`
    case `struct`
    
    /// Contains function, instantsMethod, classMethod, staticMethod
    case function
    case `enum`
    case `protocol`
    case `typealias`
    case `operator`
    case `extension`
}

public struct SourceDetail {
    
    /// The name of the source, if any.
    public var name: String
    
    /// The kind of the source
    public var sourceKind: SourceKind
    
    /// The location of the source
    public var location: SourceLocation
}

至于收集就比较简单,只需要创建一个SyntaxVisitor就可以轻松拿到所有的数据。


import Foundation
import SwiftSyntax

public final class SwiftVisitor: SyntaxVisitor {
        
    let filePath: String
    let sourceLocationConverter: SourceLocationConverter
    
    public private(set) var sources: [SourceDetail] = []
    public private(set) var sourceExtensions: [SourceDetail] = []
    
    public init(filePath: String, sourceLocationConverter: SourceLocationConverter) {
        self.filePath = filePath
        self.sourceLocationConverter = sourceLocationConverter
    }
    
    public func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind {
        if let position = findLocaiton(syntax: node.identifier) {
            collect(SourceDetail(name: node.identifier.text, sourceKind: .class, location: position))
        }
        return .visitChildren
    }
    
    public func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind {
        if let position = findLocaiton(syntax: node.identifier) {
            collect(SourceDetail(name: node.identifier.text, sourceKind: .struct, location: position))
        }
        return .visitChildren
    }
    
    .......
    
    public func visit(_ node: ExtensionDeclSyntax) -> SyntaxVisitorContinueKind {
        for token in node.extendedType.tokens {
            if let token = node.extendedType.lastToken, let position = findLocaiton(syntax: token) {
                sourceExtensions.append(SourceDetail(name: token.description , sourceKind: .extension, location: position))
            }
        }
        return .visitChildren
    }
}

func collect() throws {
        let files: [Path] = try recursiveFiles(withExtensions: ["swift"], at: path)
        for file in files {
            let syntax = try SyntaxParser.parse(file.url)
            let sourceLocationConverter = SourceLocationConverter(file: file.description, tree: syntax)
            var visitor = SwiftVisitor(filePath: file.description, sourceLocationConverter: sourceLocationConverter)
            syntax.walk(&visitor)
            sources += visitor.sources
            sourceExtensions += visitor.sourceExtensions
        }
    }

在DerivedData找到项目的Index,初始化IndexStoreDB

这里我现在只是简单的通过项目名确定DerivedData哪个文件是本项目生成的,但是这有一个问题,就是如果有多个项目同名,然后都是<项目名-随机生成的加密符号>,比如swift-package-manager-master-acyzkqyclepszpbegfazxoqfrdkt。我现在只是拿到第一个以 “项目名-”开头的文件,这样显然不否准确,我想过通过文件修改时间来确定,就是最近修改的那个,这样也不够准确,如果没有检测的时候没有修改项目呢?如果有大神知道怎么精确找到某个项目在DerivedData生成的文件,请告诉我一下。如果有多个项目同名,在使用的时候可以先清理DerivedData,再打开需要检测的项目。当然我也开放了接口来自己配置Index路径。

/// Find the index path, default is   ~Library/Developer/Xcode/DerivedData/<target>/Index/DataStore
private func findIndexFile(targetName: String) throws -> String {
    let url = URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Library/Developer/Xcode/DerivedData")
    var projectDerivedDataPath: Path?
    if let path = Path(url.path) {
        for entry in try path.ls() {
            if entry.path.basename().hasPrefix("\(targetName)-") {
                projectDerivedDataPath = entry.path
            }
        }
    }
    
    if let path = projectDerivedDataPath, let indexPath = Path(path.url.path+"/Index/DataStore")  {
        return indexPath.url.path
    }
    throw PEError.findIndexFailed(message: "find project: \(targetName) index under DerivedData failed")
}

通过IndexStoreDB查看符号

import Foundation
import IndexStoreDB

public class SourceKitServer {
    
    public var workspace: Workspace?
    
    public init(workspace: Workspace? = nil) {
        self.workspace = workspace
    }
    
    public func findWorkspaceSymbols(matching: String) -> [SymbolOccurrence] {
        var symbolOccurenceResults: [SymbolOccurrence] = []
        workspace?.index?.pollForUnitChangesAndWait()
        workspace?.index?.forEachCanonicalSymbolOccurrence(
          containing: matching,
          anchorStart: false,
          anchorEnd: false,
          subsequence: true,
          ignoreCase: true
        ) { symbol in
            if !symbol.location.isSystem &&
                !symbol.roles.contains(.accessorOf) &&
                !symbol.roles.contains(.overrideOf) &&
                symbol.roles.contains(.definition) {
            symbolOccurenceResults.append(symbol)
          }
          return true
        }
        return symbolOccurenceResults
    }
    
    public func occurrences(ofUSR usr: String, roles: SymbolRole, workspace: Workspace) -> [SymbolOccurrence] {
        guard let index = workspace.index else {
            return []
        }
        return index.occurrences(ofUSR: usr, roles: roles)
    }
}

显示Warning

这一步最简单了,便利前面收集到的不用代码,print如下格式就行了,想以错误形式显示就把warning改成error,注意需要代码的位置,通过文件路径、行和列来确定。

"\(filePath):\(line):\(column): warning: \(message)"

使用

现在还是Manually的

  1. git clone https://github.com/woshiccm/Pecker.git
  2. make install
  3. 创建 Run Script Phase,填入/usr/local/bin/pecker

效果如下:

屏幕快照 2019-12-03 下午4.25.38.png

优化

之后会考虑加入对Objective-C的支持,更友好的Install方式,优化DerivedData寻找Index环节,考虑自己生成项目的Index,加入.yml文件让使用者自定义规则。同时欢迎大家提PR,有什么问题想法也可以联系我探讨。

更新

可以通过BUILD_ROOT获得build product路径,如:/Users/ming/Library/Developer/Xcode/DerivedData/swift-package-manager-master-acyzkqyclepszpbegfazxoqfrdkt/Build/Products,这样就能精准的找到项目的Index了。

写这个项目的时候和Marcin Krzyzanowski有过交流,他是7000多StarCryptoSwift的作者,还帮我在Twitter上推了一下,对项目感兴趣的同学欢迎参与开发提PR提想法。

屏幕快照 2019-12-04 上午10.44.06.png