Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add unused-imports tool #6

Merged
merged 20 commits into from
Apr 5, 2023
4 changes: 2 additions & 2 deletions .bazelrc
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
build --incompatible_disallow_empty_glob
build --macos_minimum_os=12.0
build --host_macos_minimum_os=12.0
build --macos_minimum_os=13.0
build --host_macos_minimum_os=13.0
3 changes: 2 additions & 1 deletion .github/workflows/bazel.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ jobs:
steps:
- uses: actions/checkout@v3
- uses: swift-actions/setup-swift@v1
- run: bazelisk test //Tests/... --test_output=errors
- run: bazelisk build //...
- run: swift build
2 changes: 2 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ let package = Package(
.library(name: "SwiftDemangle", targets: ["SwiftDemangle"]),
.executable(name: "indexutil-export", targets: ["indexutil-export"]),
.executable(name: "unnecessary-testable", targets: ["unnecessary-testable"]),
.executable(name: "unused-imports", targets: ["unused-imports"]),
.executable(name: "indexutil-annotate", targets: ["indexutil-annotate"]),
.executable(name: "tycat", targets: ["tycat"]),
],
Expand All @@ -58,6 +59,7 @@ let package = Package(
.testTarget(name: "SwiftDemangleTests", dependencies: ["SwiftDemangle"], exclude: ["BUILD"]),
.executableTarget(name: "indexutil-export", dependencies: ["IndexStore"], exclude: ["BUILD"]),
.executableTarget(name: "unnecessary-testable", dependencies: ["IndexStore"], exclude: ["BUILD"]),
.executableTarget(name: "unused-imports", dependencies: ["IndexStore"], exclude: ["BUILD"]),
.executableTarget(name: "indexutil-annotate", dependencies: ["IndexStore"], exclude: ["BUILD"]),
.executableTarget(name: "tycat", dependencies: ["IndexStore"], exclude: ["BUILD"]),
],
Expand Down
6 changes: 3 additions & 3 deletions Sources/IndexStore/IndexStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ public final class IndexStore {
}
}

public final class UnitReader: Sendable {
public final class UnitReader {
private let reader: indexstore_unit_reader_t

public init(indexStore: IndexStore, unitName: String) throws {
Expand Down Expand Up @@ -130,9 +130,9 @@ public final class UnitReader: Sendable {
return indexstore_unit_reader_is_debug_compilation(self.reader)
}

public var mainFile: String { String(indexstore_unit_reader_get_main_file(self.reader)) }
public private(set) lazy var mainFile = String(indexstore_unit_reader_get_main_file(self.reader))

public var moduleName: String { String(indexstore_unit_reader_get_module_name(self.reader)) }
public private(set) lazy var moduleName = String(indexstore_unit_reader_get_module_name(self.reader))

public var workingDirectory: String { String(indexstore_unit_reader_get_working_dir(self.reader)) }

Expand Down
14 changes: 14 additions & 0 deletions Sources/unused-imports/BUILD
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_binary")

swift_binary(
name = "unused-imports",
srcs = [
"main.swift",
],
tags = [
"manual",
],
deps = [
"//:IndexStore",
],
)
198 changes: 198 additions & 0 deletions Sources/unused-imports/main.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import IndexStore
import Darwin
import Foundation

private typealias References = (usrs: Set<String>, typealiases: Set<String>)
private let identifierRegex = try Regex("([a-zA-Z_][a-zA-Z0-9_]*)")
private let ignoreRegex = try Regex(#"// *ignore-import$"#)
private var cachedLines = [String: [String.SubSequence]]()

private func getImports(path: String, recordReader: RecordReader) -> (Set<String>, [String: Int]) {
var importsToLineNumbers = [String: Int]()
let lines = try! String(contentsOfFile: path).split(separator: "\n", omittingEmptySubsequences: false)
cachedLines[path] = lines

var imports = Set<String>()
recordReader.forEach { (occurrence: SymbolOccurrence) in
if occurrence.symbol.kind == .module && occurrence.roles.contains(.reference) {
let line = lines[occurrence.location.line - 1]
// FIXME: This won't work if we are also adding missing imports, return it separately
if (line.hasPrefix("import ") || line.contains(" import ")) &&
line.firstMatch(of: ignoreRegex) == nil
{
imports.insert(occurrence.symbol.name)
importsToLineNumbers[occurrence.symbol.name] = occurrence.location.line
}
}
}

return (imports, importsToLineNumbers)
}

private func getReferences(unitReader: UnitReader, recordReader: RecordReader) -> References {
var usrs = Set<String>()
var typealiasExts = Set<String>()
recordReader.forEach { (occurrence: SymbolOccurrence) in
if occurrence.symbol.subkind == .swiftExtensionOfStruct {
usrs.insert(occurrence.symbol.usr)
let lines = cachedLines[unitReader.mainFile]!
keith marked this conversation as resolved.
Show resolved Hide resolved
let line = lines[occurrence.location.line - 1]
let startIndex = line.index(line.startIndex, offsetBy: occurrence.location.column - 1)
// FIXME: `extension [Int]` doesn't match
guard let match = line[startIndex...].firstMatch(of: identifierRegex) else {
return
}

let identifier = String(match.0)
if identifier != occurrence.symbol.name {
typealiasExts.insert(identifier)
}
} else if occurrence.roles.contains(.reference) {
usrs.insert(occurrence.symbol.usr)
}
}

return References(usrs: usrs, typealiases: typealiasExts)
}

private func collectUnitsAndRecords(indexStorePath: String) -> [(UnitReader, RecordReader)] {
let store: IndexStore
do {
store = try IndexStore(path: indexStorePath)
} catch {
fatalError("error: failed to open index store: \(error)")
}

var unitsAndRecords: [(UnitReader, RecordReader)] = []
var seenUnits = Set<String>()
for unitReader in store.units {
if unitReader.mainFile.isEmpty {
continue
}

if seenUnits.contains(unitReader.mainFile) {
continue
}
keith marked this conversation as resolved.
Show resolved Hide resolved

if let recordName = unitReader.recordName {
do {
let recordReader = try RecordReader(indexStore: store, recordName: recordName)
unitsAndRecords.append((unitReader, recordReader))
seenUnits.insert(unitReader.mainFile)
} catch {
fatalError("error: failed to load record: \(recordName) \(error)")
}
}
}

if unitsAndRecords.isEmpty {
fatalError("error: failed to load units from \(indexStorePath)")
}

return unitsAndRecords
}

func main(
indexStorePath: String,
ignoredFileRegex: Regex<AnyRegexOutput>?,
ignoredModuleRegex: Regex<AnyRegexOutput>?)
{
if let directory = ProcessInfo.processInfo.environment["BUILD_WORKSPACE_DIRECTORY"] {
FileManager.default.changeCurrentDirectoryPath(directory)
}

let pwd = FileManager.default.currentDirectoryPath
var filesToDefinitions: [String: References] = [:]
let unitsAndRecords = collectUnitsAndRecords(indexStorePath: indexStorePath)
var modulesToUnits: [String: [UnitReader]] = [:]
var allModuleNames = Set<String>()

for (unitReader, recordReader) in unitsAndRecords {
allModuleNames.insert(unitReader.moduleName)
modulesToUnits[unitReader.moduleName, default: []].append(unitReader)

var definedUsrs = Set<String>()
var definedTypealiases = Set<String>()

recordReader.forEach { (occurrence: SymbolOccurrence) in
if occurrence.roles.contains(.definition) {
definedUsrs.insert(occurrence.symbol.usr)

if occurrence.symbol.kind == .typealias {
definedTypealiases.insert(occurrence.symbol.name)
}
}

}

filesToDefinitions[unitReader.mainFile] = References(
usrs: definedUsrs, typealiases: definedTypealiases)
}

for (unitReader, recordReader) in unitsAndRecords {
if let ignoredFileRegex, unitReader.mainFile.wholeMatch(of: ignoredFileRegex) != nil {
continue
} else if let ignoredModuleRegex, unitReader.moduleName.wholeMatch(of: ignoredModuleRegex) != nil {
continue
}

let (rawImports, importsToLineNumbers) = getImports(
path: unitReader.mainFile, recordReader: recordReader)
let allImports = rawImports.intersection(allModuleNames)
if allImports.isEmpty {
continue
}

let references = getReferences(unitReader: unitReader, recordReader: recordReader)
var usedImports = Set<String>()
for anImport in allImports {
for dependentUnit in modulesToUnits[anImport] ?? [] {
if usedImports.contains(anImport) {
break
}

// Empty files have units but no records and therefore no usrs
guard let definitions = filesToDefinitions[dependentUnit.mainFile] else {
continue
}

if !definitions.usrs.intersection(references.usrs).isEmpty {
usedImports.insert(dependentUnit.moduleName)
} else if !definitions.typealiases.intersection(references.typealiases).isEmpty {
// If the typealias isn't already imported then it's probably not the one we're looking for
if allImports.contains(dependentUnit.moduleName) {
usedImports.insert(dependentUnit.moduleName)
}
}
}

if allImports.subtracting(usedImports).isEmpty {
break
}
}

let unusedImports = allImports.subtracting(usedImports)
if !unusedImports.isEmpty {
let sedCmd = unusedImports.map { importsToLineNumbers[$0]! }.sorted().map { "\($0)d" }.joined(separator: ";")
let relativePath = unitReader.mainFile.replacingOccurrences(of: pwd + "/", with: "")
print("/usr/bin/sed -i \"\" '\(sedCmd)' \(relativePath)")
}
}
}

if CommandLine.arguments.count == 4 {
let ignoredFileRegex = try Regex(CommandLine.arguments[2])
let ignoredModuleRegex = try Regex(CommandLine.arguments[3])

main(
indexStorePath: CommandLine.arguments[1],
ignoredFileRegex: ignoredFileRegex,
ignoredModuleRegex: ignoredModuleRegex
)
} else {
main(
indexStorePath: CommandLine.arguments[1],
ignoredFileRegex: nil,
ignoredModuleRegex: nil
)
}