Skip to content

Commit

Permalink
Reduce main thread usage in SafeDITool (#114)
Browse files Browse the repository at this point in the history
* Use complete argument list

* Reduce main thread usage in SafeDITool

* Improve code coverage

* More simplification

* Remove Requirements section from README – defer instead to Package.swift
  • Loading branch information
dfed authored Oct 3, 2024
1 parent d20e9a6 commit 85c2c7e
Show file tree
Hide file tree
Showing 7 changed files with 68 additions and 60 deletions.
8 changes: 0 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -484,14 +484,6 @@ The `SafeDITool` utility is designed to able to be integrated into projects of a

The `SafeDITool` can parse all of your Swift files at once, or for even better performance, the tool can be run on each dependent module as part of the build. Running this tool on each dependent module is currently left as an exercise to the reader.

### Requirements

* Xcode 16.0 or later
* iOS 13 or later
* tvOS 13 or later
* watchOS 6 or later
* macOS 10.13 or later

## Under the hood

SafeDI has a `SafeDITool` executable that the `SafeDIGenerator` plugin utilizes to read code and generate a dependency tree. The tool utilizes Apple‘s [SwiftSyntax](https://github.com/apple/swift-syntax) library to parse your code and find your `@Instantiable` types‘ initializers and dependencies. With this information, SafeDI creates a graph of your project‘s dependencies. This graph is validated as part of the `SafeDITool`‘s execution, and the tool emits human-readible errors if the dependency graph is not valid. Source code is only generated if the dependency graph is valid.
Expand Down
6 changes: 6 additions & 0 deletions Sources/SafeDICore/Extensions/CollectionExtensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,9 @@ extension Collection {
return try arrayToTransform.map { try transform($0) } + [lastItem]
}
}

extension Collection<String> {
public func removingEmpty() -> [Element] {
filter { !$0.isEmpty }
}
}
4 changes: 2 additions & 2 deletions Sources/SafeDICore/Generators/DependencyTreeGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ public actor DependencyTreeGenerator {
for try await generatedRoot in taskGroup {
generatedRoots.append(generatedRoot)
}
return generatedRoots.filter { !$0.isEmpty }.sorted().joined(separator: "\n\n")
return generatedRoots.removingEmpty().sorted().joined(separator: "\n\n")
}

let importsWhitespace = imports.isEmpty ? "" : "\n"
Expand All @@ -74,7 +74,7 @@ public actor DependencyTreeGenerator {
for try await generatedRoot in taskGroup {
generatedRoots.append(generatedRoot)
}
return generatedRoots.filter { !$0.isEmpty }.sorted().joined(separator: "\n\n")
return generatedRoots.removingEmpty().sorted().joined(separator: "\n\n")
}

return """
Expand Down
16 changes: 4 additions & 12 deletions Sources/SafeDICore/Models/TypeDescription.swift
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ public enum TypeDescription: Codable, Hashable, Comparable, Sendable {
}.joined(separator: ", ")))
"""
case let .closure(arguments, isAsync, doesThrow, returnType):
return "(\(arguments.map(\.asSource).joined(separator: ", ")))\([isAsync ? " async" : "", doesThrow ? " throws" : ""].filter { !$0.isEmpty }.joined()) -> \(returnType.asSource)"
return "(\(arguments.map(\.asSource).joined(separator: ", ")))\([isAsync ? " async" : "", doesThrow ? " throws" : ""].removingEmpty().joined()) -> \(returnType.asSource)"
case let .unknown(text):
return text
}
Expand Down Expand Up @@ -519,18 +519,10 @@ private final class GenericArgumentVisitor: SyntaxVisitor {

extension TypeSpecifierListSyntax {
fileprivate var textRepresentation: [String]? {
let specifiers = compactMap { specifier in
if case let .simpleTypeSpecifier(simpleTypeSpecifierSyntax) = specifier {
simpleTypeSpecifierSyntax.specifier.text
} else {
// lifetimeTypeSpecifier is SPI, so we ignore it.
nil
}
}
if specifiers.isEmpty {
return nil
if isEmpty {
nil
} else {
return specifiers
compactMap(\.trimmedDescription)
}
}
}
89 changes: 53 additions & 36 deletions Sources/SafeDITool/SafeDITool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,6 @@ struct SafeDITool: AsyncParsableCommand, Sendable {

// MARK: Internal

@MainActor
func run() async throws {
if swiftSourcesFilePath == nil, include.isEmpty {
throw ValidationError("Must provide either 'swift-sources-file-path' or '--include'.")
Expand Down Expand Up @@ -105,44 +104,58 @@ struct SafeDITool: AsyncParsableCommand, Sendable {

// MARK: Private

@MainActor
private func findSwiftFiles() async throws -> Set<String> {
var swiftFiles = Set<String>()
if let swiftSourcesFilePath {
try swiftFiles.formUnion(
String(contentsOfFile: swiftSourcesFilePath)
.components(separatedBy: CharacterSet(arrayLiteral: ","))
.filter { !$0.isEmpty }
)
}
for included in include {
let includedURL = included.asFileURL
let includedFileEnumerator = fileFinder
.enumerator(
at: includedURL,
includingPropertiesForKeys: nil,
options: [.skipsHiddenFiles],
errorHandler: nil
)
guard let files = includedFileEnumerator?.compactMap({ $0 as? URL }) else {
struct CouldNotEnumerateDirectoryError: Error, CustomStringConvertible {
let directory: String

var description: String {
"Could not create file enumerator for directory '\(directory)'"
}
try await withThrowingTaskGroup(
of: [String].self,
returning: Set<String>.self
) { taskGroup in
taskGroup.addTask {
if let swiftSourcesFilePath {
try String(contentsOfFile: swiftSourcesFilePath)
.components(separatedBy: CharacterSet(arrayLiteral: ","))
.removingEmpty()
} else {
[]
}
throw CouldNotEnumerateDirectoryError(directory: included)
}
swiftFiles.formUnion((files + [includedURL]).compactMap {
if $0.pathExtension == "swift" {
$0.standardizedFileURL.relativePath
} else {
nil
let fileFinder = await fileFinder
for included in include {
taskGroup.addTask {
let includedURL = included.asFileURL
let includedFileEnumerator = fileFinder
.enumerator(
at: includedURL,
includingPropertiesForKeys: nil,
options: [.skipsHiddenFiles],
errorHandler: nil
)
guard let files = includedFileEnumerator?.compactMap({ $0 as? URL }) else {
struct CouldNotEnumerateDirectoryError: Error, CustomStringConvertible {
let directory: String

var description: String {
"Could not create file enumerator for directory '\(directory)'"
}
}
throw CouldNotEnumerateDirectoryError(directory: included)
}
return (files + [includedURL]).compactMap {
if $0.pathExtension == "swift" {
$0.standardizedFileURL.relativePath
} else {
nil
}
}
}
})
}

var swiftFiles = Set<String>()
for try await includedFiles in taskGroup {
swiftFiles.formUnion(includedFiles)
}

return swiftFiles
}
return swiftFiles
}

private func loadSwiftFiles() async throws -> [String] {
Expand Down Expand Up @@ -201,7 +214,7 @@ struct SafeDITool: AsyncParsableCommand, Sendable {
try .init(
String(contentsOfFile: dependentModuleInfoFilePath)
.components(separatedBy: CharacterSet(arrayLiteral: ","))
.filter { !$0.isEmpty }
.removingEmpty()
.map(\.asFileURL)
)
} else {
Expand Down Expand Up @@ -304,7 +317,7 @@ extension String {
}
}

protocol FileFinder {
protocol FileFinder: Sendable {
func enumerator(
at url: URL,
includingPropertiesForKeys keys: [URLResourceKey]?,
Expand All @@ -314,5 +327,9 @@ protocol FileFinder {
}

extension FileManager: FileFinder {}
extension FileManager: @retroactive @unchecked Sendable {
// FileManager is thread safe:
// https://developer.apple.com/documentation/foundation/nsfilemanager#1651181
}

@MainActor var fileFinder: FileFinder = FileManager.default
3 changes: 1 addition & 2 deletions Tests/SafeDIToolTests/Helpers/SafeDIToolTestExecution.swift
Original file line number Diff line number Diff line change
Expand Up @@ -133,11 +133,10 @@ struct StubFileFinder: FileFinder {
let files: [URL]
}

@MainActor
func assertThrowsError(
_ errorDescription: String,
line: UInt = #line,
block: @MainActor () async throws -> some Any
block: @MainActor () async throws -> some Sendable
) async {
do {
_ = try await block()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1733,6 +1733,7 @@ final class SafeDIToolCodeGenerationErrorTests: XCTestCase {
tool.moduleInfoOutput = nil
tool.dependentModuleInfoFilePath = nil
tool.dependencyTreeOutput = nil
tool.dotFileOutput = nil
await assertThrowsError("Could not create file enumerator for directory 'Fake'") {
try await tool.run()
}
Expand All @@ -1746,6 +1747,7 @@ final class SafeDIToolCodeGenerationErrorTests: XCTestCase {
tool.moduleInfoOutput = nil
tool.dependentModuleInfoFilePath = nil
tool.dependencyTreeOutput = nil
tool.dotFileOutput = nil
await assertThrowsError("Must provide either 'swift-sources-file-path' or '--include'.") {
try await tool.run()
}
Expand Down

0 comments on commit 85c2c7e

Please sign in to comment.