给 SwiftLint 报告加上函数名:一个小小的 Swift 工具

SwiftLint 报告只给文件和行号。一个小 Swift 工具用字符扫描补上所在函数:真实数字、并发上的反复,以及我会改的地方。

文章目录
  1. 报告里少了什么
  2. 不解析语法树,怎么找函数
  3. 串行、异步、再串行、并行
  4. 后续值得改进的地方

老项目需要排查一下代码里的 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 是哪个任务先完成就先返回哪个,所以输出的顺序和输入的顺序不一致。
  • **我从来没有做过性能测试。**改成并行是为了提速,但我手头没有改动前后的数据,所以不会宣称它提升了多少性能。

我想保留的是它的尺度。原本只是一份位置列表的报告,变成了可以按函数讨论的东西,做到这一点只用了一个很小的工具。