给 SwiftLint 报告加上函数名:一个小小的 Swift 工具
SwiftLint 报告只给文件和行号。一个小 Swift 工具用字符扫描补上所在函数:真实数字、并发上的反复,以及我会改的地方。
老项目需要排查一下代码里的 force unwrap。SwiftLint 虽然能把这些地方都扫出来,但单看这份列表用处不大,所以我写了一个命令行小工具,给报告加上了缺失的函数名。
报告里少了什么
整个项目的 SwiftLint JSON 报告一共扫出了 2,786 个文件中的 41,247 条问题。过滤出 force_unwrapping 规则后,剩下 292 个文件中的 904 条。这个数量级是人工可以逐条处理的,但每条记录长这样:
{
"character": 42,
"file": "/path/to/SomeFile.swift",
"line": 118,
"reason": "Force unwrapping should be avoided",
"rule_id": "force_unwrapping",
"severity": "Warning",
"type": "Force Unwrapping"
}
只有文件名和行号,能告诉你去哪里看,但看不出这段代码是在干什么。而且只要有人改了文件,行号很快就会失效。如果能带上包裹这段代码的函数名,这两个问题就都解决了,这也是我准备加上的信息。
不解析语法树,怎么找函数
这个工具的逻辑是:读取 JSON 报告,打开对应的源文件,然后推算出某一行代码属于哪个函数。我没有用 SwiftSyntax,而是直接做字符扫描:记录大括号的嵌套深度,当遇到 func、init 或 deinit 时,把后面跟随的大括号行号范围记下来。
实际的扫描逻辑有 171 行,下面是精简后的核心思路:
struct FunctionRange {
let name: String
let start: Int
var end = -1
let depth: Int
}
func functionName(in source: String, containing target: Int) -> String? {
var finished: [FunctionRange] = []
var open: [FunctionRange] = []
var depth = 0
var pending: String?
var inString = false
for (index, line) in source.components(separatedBy: .newlines).enumerated() {
if !inString, let match = line.range(of: #"\b(func\s+\w+|init|deinit)\b"#, options: .regularExpression) {
pending = line[match].split(separator: " ").last.map(String.init)
}
var escaped = false
for char in line {
if escaped { escaped = false; continue }
if char == "\\" { escaped = true; continue }
if char == "\"" { inString.toggle(); continue }
if inString { continue }
if char == "{" {
depth += 1
if let name = pending {
open.append(FunctionRange(name: name, start: index + 1, depth: depth))
pending = nil
}
} else if char == "}" {
if var last = open.last, last.depth == depth {
last.end = index + 1
finished.append(last)
open.removeLast()
}
depth -= 1
}
}
}
return finished
.filter { $0.start <= target && target <= $0.end }
.max { $0.depth < $1.depth }?
.name
}
这里有两个细节需要注意。第一,字符串内部的大括号不能算进去,所以扫描器要记录当前是否在字符串里(实际的代码还处理了多行字符串)。第二,函数是可以嵌套的,如果某一行代码同时落在了多个范围内,以嵌套最深的那一层为准。
这种启发式匹配肯定有漏洞。在 904 个 force unwrap 中,有 65 个没有匹配到函数。因为扫描器只识别 func、init 和 deinit,写在计算属性等其他地方的代码就拿不到函数名。另外,它只返回函数名而没有类型名,导致两个 viewDidLoad 看起来一模一样。不过对于一份人工阅读的报告来说,这些缺点我能接受。
串行、异步、再串行、并行
提交记录比事后整理的总结更真实。第一天解析完 JSON 后,我半小时内就写好了一个单线程版本。接着我尝试换成 async 版本,跑通之后,九分钟后我就切回了单线程,并用单线程生成了第一版带函数名的报告。
两周后,我重新打开这个项目,用 TaskGroup 改成了并行处理,每一个问题分配一个任务。那天上午我写的 commit 信息是“好像实现了多线程”。
func addFunctionNames(to findings: [Finding]) async -> [EnrichedFinding] {
await withTaskGroup(of: EnrichedFinding?.self) { group in
for finding in findings {
group.addTask {
guard let source = try? String(contentsOfFile: finding.file, encoding: .utf8) else {
return nil
}
let name = functionName(in: source, containing: finding.line) ?? "None"
return EnrichedFinding(finding: finding, functionName: name)
}
}
var results: [EnrichedFinding] = []
for await result in group {
if let result { results.append(result) }
}
return results
}
}
输出是在原始数据的基础上加上了 functionName 字段,并保存为 JSON。保留原始数据的所有字段,可以确保下游处理工具不需要做任何修改。最后再通过单独的一步,按文件分组统计数量,生成最终带函数名列的报告。
后续值得改进的地方
工具完成了它的任务,但我不会把它当成优秀范例:
- **存在重复劳动。**每一个问题都会重新读取并扫描一次文件,如果一个文件里有 30 个问题,就会被扫描 30 次。先按文件分组再处理就能解决这个问题。
- **没有并发限制。**它会一次性为所有问题创建任务。
- **异常被直接丢弃了。**如果某个文件读取失败,打印一条错误日志后,这条记录就被丢掉了。一份静默减少数据的报告,比带空白列的报告更糟糕。
- **路径是写死的。**要跑别的数据源,得直接修改源码。对于一次性的内部小工具来说没问题,但它不算一个合格的 CLI。
- 输出顺序不固定。
TaskGroup是哪个任务先完成就先返回哪个,所以输出的顺序和输入的顺序不一致。 - **我从来没有做过性能测试。**改成并行是为了提速,但我手头没有改动前后的数据,所以不会宣称它提升了多少性能。
我想保留的是它的尺度。原本只是一份位置列表的报告,变成了可以按函数讨论的东西,做到这一点只用了一个很小的工具。