From f30f5442e268f1a7b0e21f38b0d67d13062d4a98 Mon Sep 17 00:00:00 2001 From: Keith Smiley Date: Tue, 28 Mar 2023 11:39:58 -0700 Subject: [PATCH] Add unused-imports tool 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 https://github.com/lyft/swift-index-store/issues/5 --- Package.swift | 2 + Sources/IndexStore/IndexStore.swift | 6 +- Sources/unused-imports/main.swift | 195 ++++++++++++++++++++++++++++ 3 files changed, 200 insertions(+), 3 deletions(-) create mode 100644 Sources/unused-imports/main.swift diff --git a/Package.swift b/Package.swift index 4d9927f..37af3c2 100644 --- a/Package.swift +++ b/Package.swift @@ -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"]), ], @@ -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), ], diff --git a/Sources/IndexStore/IndexStore.swift b/Sources/IndexStore/IndexStore.swift index b5b6db4..472be99 100644 --- a/Sources/IndexStore/IndexStore.swift +++ b/Sources/IndexStore/IndexStore.swift @@ -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 { @@ -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)) } diff --git a/Sources/unused-imports/main.swift b/Sources/unused-imports/main.swift new file mode 100644 index 0000000..6b389b6 --- /dev/null +++ b/Sources/unused-imports/main.swift @@ -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 { + guard let searchText = try? String(contentsOfFile: path) else { + fatalError("failed to read '\(path)'") + } + + let matches = testableRegex.matches( + in: searchText, range: NSRange(searchText.startIndex.. Storage { + // Empty source files have units but no records + guard let recordReader else { + return Storage(usrs: [], typealiases: []) + } + + var lines: [String.SubSequence]? + var usrs = Set() + var typealiasExts = Set() + 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).. ([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 + let typealiases: Set +} + +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() + for unitReader in units { + allModuleNames.insert(unitReader.moduleName) + modulesToUnits[unitReader.moduleName, default: []].append(unitReader) + + if let recordReader = unitToRecord[unitReader.mainFile] { + var definedUsrs = Set() + var definedTypealiases = Set() + + 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() + 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])