Skip to content

Commit

Permalink
Add unused-imports tool
Browse files Browse the repository at this point in the history
This tool uses the index data to suggest imports that can be removed
from your files. Currently compared to SwiftLint's implementation it's
much faster, but doesn't yet add missing imports, or remove unused
system imports.

It's currently lightly affected by upstream issues listed here
#5
  • Loading branch information
keith committed Mar 28, 2023
1 parent cbd91ca commit f30f544
Show file tree
Hide file tree
Showing 3 changed files with 200 additions and 3 deletions.
2 changes: 2 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,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 @@ -47,6 +48,7 @@ let package = Package(
.testTarget(name: "SwiftDemangleTests", dependencies: ["SwiftDemangle"]),
.target(name: "indexutil-export", dependencies: ["IndexStore"], linkerSettings: linkerSettings),
.target(name: "unnecessary-testable", dependencies: ["IndexStore"], linkerSettings: linkerSettings),
.target(name: "unused-imports", dependencies: ["IndexStore"], linkerSettings: linkerSettings),
.target(name: "indexutil-annotate", dependencies: ["IndexStore"], linkerSettings: linkerSettings),
.target(name: "tycat", dependencies: ["IndexStore"], linkerSettings: linkerSettings),
],
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
195 changes: 195 additions & 0 deletions Sources/unused-imports/main.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
import IndexStore
import Darwin
import Foundation

// FIXME: Loosen this regex
// FIXME: this ignores 'import foo.bar' and all other 'import struct foo.bar' things
private let testableRegex = try NSRegularExpression(
pattern: #"^[^/\n]*\bimport ([^ \n.]+)( *// *noqa)?$"#, options: [.anchorsMatchLines])
// FIXME: This isn't complete
private let identifierRegex = try NSRegularExpression(
pattern: "([a-zA-Z_][a-zA-Z0-9_]*)", options: [])

private func getImports(path: String) -> Set<String> {
guard let searchText = try? String(contentsOfFile: path) else {
fatalError("failed to read '\(path)'")
}

let matches = testableRegex.matches(
in: searchText, range: NSRange(searchText.startIndex..<searchText.endIndex, in: searchText))

return Set(matches.compactMap { match in
guard let range = Range(match.range(at: 1), in: searchText) else {
fatalError("error: failed to get regex match: \(path)")
}

if let range = Range(match.range(at: 2), in: searchText) {
let comment = String(searchText[range])
if comment.contains("noqa") {
// FIXME: This won't work if we are also adding missing imports, return it separately
return nil
}
}

return String(searchText[range])
})
}

private func getReferenceUSRs(unitReader: UnitReader, recordReader: RecordReader?) -> Storage {
// Empty source files have units but no records
guard let recordReader else {
return Storage(usrs: [], typealiases: [])
}

var lines: [String.SubSequence]?
var usrs = Set<String>()
var typealiasExts = Set<String>()
recordReader.forEach { (occurrence: SymbolOccurrence) in
if occurrence.symbol.subkind == .swiftExtensionOfStruct {
usrs.insert(occurrence.symbol.usr)
if lines == nil {
lines = try! String(contentsOfFile: unitReader.mainFile).split(separator: "\n", omittingEmptySubsequences: false)
}

let line = String(lines![occurrence.location.line - 1])
let indexes = line.index(line.startIndex, offsetBy: occurrence.location.column - 1)..<line.endIndex
let range = NSRange(indexes, in: line)
// FIXME: extension [Int] doesn't match
guard let identifierRange = identifierRegex.firstMatch(in: line, range: range)?.range(at: 1) else {
// print("no identifier line is: \(line[indexes]) in \(unitReader.mainFile)")
return
}
let identifier = String(line[Range(identifierRange, in: line)!])
if identifier != occurrence.symbol.name {
typealiasExts.insert(identifier)
}
} else if occurrence.roles.contains(.reference){
usrs.insert(occurrence.symbol.usr)
}
}

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

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

var units: [UnitReader] = []
var unitToRecord: [String: RecordReader] = [:]

for unitReader in store.units {
if unitReader.mainFile.isEmpty {
continue
}

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

// FIXME: Duplicates can happen if a single file is included in multiple targets / configurations
// if let existingRecord = unitToRecord[unitReader.mainFile] {
// // fatalError("error: found duplicate record for \(unitReader.mainFile) in \(existingRecord.name) and \(recordReader.name)")
// }

unitToRecord[unitReader.mainFile] = recordReader
}
}

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

return (units, unitToRecord)
}

struct Storage {
let usrs: Set<String>
let typealiases: Set<String>
}

func main(indexStorePath: String) {
if let directory = ProcessInfo.processInfo.environment["BUILD_WORKSPACE_DIRECTORY"] {
FileManager.default.changeCurrentDirectoryPath(directory)
}

var filesToUSRDefinitions: [String: Storage] = [:]
let (units, unitToRecord) = collectUnitsAndRecords(indexStorePath: indexStorePath)
var modulesToUnits: [String: [UnitReader]] = [:]
var allModuleNames = Set<String>()
for unitReader in units {
allModuleNames.insert(unitReader.moduleName)
modulesToUnits[unitReader.moduleName, default: []].append(unitReader)

if let recordReader = unitToRecord[unitReader.mainFile] {
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)
}
}

}

filesToUSRDefinitions[unitReader.mainFile] = Storage(
usrs: definedUsrs, typealiases: definedTypealiases)
}
}

for unitReader in units {
let allImports = getImports(path: unitReader.mainFile).intersection(allModuleNames)
if allImports.isEmpty {
continue
}

let referencedUSRs = getReferenceUSRs(unitReader: unitReader, recordReader: unitToRecord[unitReader.mainFile])
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 storage = filesToUSRDefinitions[dependentUnit.mainFile] else {
continue
}


if !storage.usrs.intersection(referencedUSRs.usrs).isEmpty {
usedImports.insert(dependentUnit.moduleName)
}

if !storage.typealiases.intersection(referencedUSRs.typealiases).isEmpty {
// If the type alias 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
}
}

for module in allImports.intersection(allModuleNames).subtracting(usedImports) {
print("/usr/bin/sed -i \"\" '/^import \(module)$/d' \(unitReader.mainFile)")
}
}
}

main(indexStorePath: CommandLine.arguments[1])

0 comments on commit f30f544

Please sign in to comment.