Pecker:自动检测项目中不用的代码
先放上项目的地址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实现这个功能。
然后我看了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]
我们看到:
-
TestObject
的符号名就是TestObject
,在项目中一个地方被def
定义,两个地方被ref
引用,和源代码中情况一致,这里就有问题了,就是extension也是算作引用,但是我们需要通过这个判断符号是否被使用,显然extension不能算作是被使用,所以我们在使用SyntaxVisitor的时候需要把extension也记下来,然后和这里的ref通过位置进行比较,如果在收集的extension集合中发现了,那这次的出现就不能当做引用。 - 确定方法的符号名,
gogogo<T>(_ t: T, name: String)
这样的方法符号名gogogo(_:name:)
,所以在通过SyntaxVisitor收集的时候要按照这个规则生成符号名。 - 需要设置白名单,比如
AppDelegate
、SceneDelegate
等,按照上面规则,这些是会检测为未被使用的代码,需要过滤掉,这个我暂时是写死的,之后考虑像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的
git clone https://github.com/woshiccm/Pecker.git
make install
- 创建 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