Adding Function Names to a SwiftLint Report with a Small Swift Tool
A SwiftLint report gives a file and a line. A small Swift tool added the enclosing function using a character scan, with the real numbers, the back-and-forth on concurrency, and what I would change.
On this page
An old iOS project needed its force unwraps looked at. SwiftLint could list them, but the list wasn’t much use on its own, so I wrote a small command-line tool to add the missing column.
What the report was missing
The full SwiftLint JSON report for the project had 41,247 findings in 2,786 files. Narrowed to the force_unwrapping rule, it was 904 findings in 292 files. That’s a size someone can actually work through, but each entry looks like this:
{
"character": 42,
"file": "/path/to/SomeFile.swift",
"line": 118,
"reason": "Force unwrapping should be avoided",
"rule_id": "force_unwrapping",
"severity": "Warning",
"type": "Force Unwrapping"
}
A file and a line number tell you where to look. They don’t tell you what the code does, and they go stale as soon as someone edits the file. The name of the enclosing function does both, so that’s what I set out to add.
Finding the function without a syntax tree
The tool reads the JSON, opens each source file, and works out which function contains the line. I didn’t use SwiftSyntax. It’s a character scan: track brace depth, note when func, init, or deinit appears, and record the line range of the braces that follow.
The real scanner is 171 lines. This is the same idea, shortened:
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
}
Two details matter. Braces inside a string shouldn’t count, so the scanner tracks whether it’s in a string. The real one also handles multi-line strings. And functions can nest, so when several ranges contain the line, the deepest wins.
This is a heuristic, and it shows. Of the 904 force unwraps, 65 came back with no function. The scanner only recognizes func, init, and deinit, so code in other places, like a computed property, has no name to report. It also returns only a function name, not the type, so two viewDidLoads look the same. For a report meant to be read by people, I accepted that.
Serial, async, serial, parallel
The git history here is more honest than a clean write-up would be. On the first day I had a single-threaded version working within half an hour of parsing the JSON. Then I tried an async version. It ran, and nine minutes later I went back to single-threaded and shipped the first report with function names that way.
Two weeks later I came back and made it parallel with a TaskGroup, one task per finding. My own commit message that morning was “seems like I implemented multithreading”.
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
}
}
The output is the original finding plus a functionName field, saved as JSON. Keeping every original field meant nothing downstream had to change. A separate step grouped the results by file with a count per file, for the final report with a function column.
What I’d change
The tool did its job, but I wouldn’t hold it up as a model:
- It repeats work. Each finding reads and scans its file again, so a file with 30 findings gets scanned 30 times. Grouping by file first would fix that.
- Concurrency has no limit. It adds one task per finding, all at once.
- Failures disappear. If a file can’t be read, the finding is dropped after printing an error. A report that silently shrinks is worse than one with a blank column.
- Paths are hardcoded. You edit the source to point it at a different report. For a one-off internal tool that was fine, but it isn’t a real CLI.
- Results arrive in any order. A
TaskGroupreturns tasks as they finish, so the output order isn’t the input order. - I never measured it. I made it parallel to speed it up, but I have no before-and-after numbers, so I won’t claim any.
The part I’d keep is the scale of the thing. A report that was only a list of locations became something you could talk about by function, and it took a small tool to get there.