Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
118a237
Generate one output file per @Instantiable(isRoot: true) root
dfed Mar 30, 2026
fccaf7e
Replace --swift-output-directory with manifest-based --swift-manifest
dfed Mar 30, 2026
3f561b0
Add manifest validation tests and fix coverage
dfed Mar 30, 2026
0b82321
Fix false positive in root detection regex
dfed Mar 30, 2026
c0d8399
Fix regex: use comment/backtick line prefix check instead of lookbehind
dfed Mar 30, 2026
f2abd7d
Add tests for remaining uncovered code paths
dfed Mar 30, 2026
ef7fcba
Eliminate uncovered nil branch in rootScopeGenerators
dfed Mar 31, 2026
f93a6cc
Include sourceFilePath in Codable serialization
dfed Mar 31, 2026
1d9bd49
Remove unused relativeTo parameter from writeManifest
dfed Mar 31, 2026
3440a39
Use relative input paths in CSV and manifest for cache compatibility
dfed Mar 31, 2026
b24b6da
Change manifest from dictionary to array of InputOutputMap structs
dfed Mar 31, 2026
85d0f66
Revert simpleNameAndGenerics to internal visibility
dfed Mar 31, 2026
84e5b4c
Fix duplicate file header when multiple roots share a source file
dfed Mar 31, 2026
fd0c675
Revert DependencyTreeGenerator.imports to private
dfed Mar 31, 2026
2d5d434
Restore Sendable conformance on SafeDITool
dfed Mar 31, 2026
a611a3b
Add tests for skip-if-unchanged optimization and manifest+DOT coexist…
dfed Mar 31, 2026
1920480
Add comment noting JSON keys must match SafeDIToolManifest properties
dfed Mar 31, 2026
aeff016
Fix output filename collisions and non-deterministic multi-root ordering
dfed Mar 31, 2026
f6229a1
Fix Xcode plugin paths calling removed outputFileName(for:)
dfed Mar 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Documentation/Manual.md
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,10 @@ The executable heavily utilizes asynchronous processing to avoid `SafeDITool` be

Due to limitations in Apple’s [Swift Package Manager Plugins](https://github.com/swiftlang/swift-package-manager/blob/main/Sources/PackageManagerDocs/Documentation.docc/Plugins.md), the `SafeDIGenerator` plugin parses all of your first-party Swift files in a single pass. Projects that utilize `SafeDITool` directly can process Swift files on a per-module basis to further reduce the build-time bottleneck.

### Custom build system integration

If you are integrating SafeDI with a build system other than SPM (e.g. Bazel, Buck, or a prebuild script), you can invoke `SafeDITool` directly with a JSON manifest file that describes the desired outputs. The manifest uses the [`SafeDIToolManifest`](../Sources/SafeDICore/Models/SafeDIToolManifest.swift) format, mapping input Swift files containing `@Instantiable(isRoot: true)` to output file paths. Paths are relative to the working directory. See the [example prebuild script](../Examples/PrebuildScript/safeditool.sh) for a working example.

## Introspecting a SafeDI tree

You can create a [GraphViz DOT file](https://graphviz.org/doc/info/lang.html) to introspect a SafeDI dependency tree by running `swift run SafeDITool` and utilizing the `--dot-file-output` parameter. This command will create a `DOT` file that you can pipe into `GraphViz`’s `dot` command to create a pdf.
Expand Down
20 changes: 17 additions & 3 deletions Examples/PrebuildScript/safeditool.sh
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,20 @@ fi

# Run the tool.
SOURCE_DIR="$PROJECT_DIR/ExampleCocoaPodsIntegration"
SAFEDI_OUTPUT="$PROJECT_DIR/SafeDIOutput/SafeDI.swift"
mkdir -p $PROJECT_DIR/SafeDIOutput
$SAFEDI_LOCATION --include "$SOURCE_DIR" --dependency-tree-output "$SAFEDI_OUTPUT"
SAFEDI_OUTPUT_DIR="$PROJECT_DIR/SafeDIOutput"
mkdir -p "$SAFEDI_OUTPUT_DIR"

# Create the manifest JSON mapping input files to output files.
# See SafeDIToolManifest in SafeDICore for the expected format.
cat > "$SAFEDI_OUTPUT_DIR/SafeDIManifest.json" << MANIFEST
{
"dependencyTreeGeneration": [
{
"inputFilePath": "$SOURCE_DIR/Views/ExampleApp.swift",
"outputFilePath": "$SAFEDI_OUTPUT_DIR/ExampleApp+SafeDI.swift"
}
]
}
MANIFEST

$SAFEDI_LOCATION --include "$SOURCE_DIR" --swift-manifest "$SAFEDI_OUTPUT_DIR/SafeDIManifest.json"
63 changes: 51 additions & 12 deletions Plugins/SafeDIGenerator/SafeDIGenerateDependencyTree.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ struct SafeDIGenerateDependencyTree: BuildToolPlugin {
return []
}

let outputSwiftFile = context.pluginWorkDirectoryURL.appending(path: "SafeDI.swift")
let outputDirectory = context.pluginWorkDirectoryURL.appending(path: "SafeDIOutput")
// Swift Package Plugins did not (as of Swift 5.9) allow for
// creating dependencies between plugin output at the time of writing.
// Since our current build system did not support depending on the
Expand All @@ -46,19 +46,40 @@ struct SafeDIGenerateDependencyTree: BuildToolPlugin {
.sourceFiles(withSuffix: ".swift")
.map(\.url)
}

let allSwiftFiles = targetSwiftFiles + dependenciesSourceFiles
let rootFiles = findFilesWithRoots(in: allSwiftFiles)
guard !rootFiles.isEmpty else {
return []
}

let outputFiles = zip(rootFiles, outputFileNames(for: rootFiles)).map { _, name in
outputDirectory.appending(path: name)
}

let packageRoot = context.package.directoryURL
let inputSourcesFile = context.pluginWorkDirectoryURL.appending(path: "InputSwiftFiles.csv")
try (targetSwiftFiles.map { $0.path(percentEncoded: false) } + dependenciesSourceFiles.map { $0.path(percentEncoded: false) })
try allSwiftFiles
.map { relativePath(for: $0, relativeTo: packageRoot) }
.joined(separator: ",")
.write(
to: inputSourcesFile,
atomically: true,
encoding: .utf8,
)

let manifestFile = context.pluginWorkDirectoryURL.appending(path: "SafeDIManifest.json")
try writeManifest(
dependencyTreeInputFiles: rootFiles,
outputDirectory: outputDirectory,
to: manifestFile,
relativeTo: packageRoot,
)

let arguments = [
inputSourcesFile.path(percentEncoded: false),
"--dependency-tree-output",
outputSwiftFile.path(percentEncoded: false),
"--swift-manifest",
manifestFile.path(percentEncoded: false),
]

let downloadedToolLocation = context.downloadedToolLocation
Expand All @@ -84,8 +105,8 @@ struct SafeDIGenerateDependencyTree: BuildToolPlugin {
executable: toolLocation,
arguments: arguments,
environment: [:],
inputFiles: targetSwiftFiles + dependenciesSourceFiles,
outputFiles: [outputSwiftFile],
inputFiles: allSwiftFiles,
outputFiles: outputFiles,
),
]
}
Expand Down Expand Up @@ -142,21 +163,39 @@ extension Target {
return []
}

let outputSwiftFile = context.pluginWorkDirectoryURL.appending(path: "SafeDI.swift")
let rootFiles = findFilesWithRoots(in: inputSwiftFiles)
guard !rootFiles.isEmpty else {
return []
}

let outputDirectory = context.pluginWorkDirectoryURL.appending(path: "SafeDIOutput")
let outputFiles = zip(rootFiles, outputFileNames(for: rootFiles)).map { _, name in
outputDirectory.appending(path: name)
}

let projectRoot = context.xcodeProject.directoryURL
let inputSourcesFile = context.pluginWorkDirectoryURL.appending(path: "InputSwiftFiles.csv")
try inputSwiftFiles
.map { $0.path(percentEncoded: false) }
.map { relativePath(for: $0, relativeTo: projectRoot) }
.joined(separator: ",")
.write(
to: inputSourcesFile,
atomically: true,
encoding: .utf8,
)

let manifestFile = context.pluginWorkDirectoryURL.appending(path: "SafeDIManifest.json")
try writeManifest(
dependencyTreeInputFiles: rootFiles,
outputDirectory: outputDirectory,
to: manifestFile,
relativeTo: projectRoot,
)

let arguments = [
inputSourcesFile.path(percentEncoded: false),
"--dependency-tree-output",
outputSwiftFile.path(percentEncoded: false),
"--swift-manifest",
manifestFile.path(percentEncoded: false),
]

let downloadedToolLocation = context.downloadedToolLocation
Expand All @@ -173,14 +212,14 @@ extension Target {
try context.tool(named: "SafeDITool").url
}

return try [
return [
.buildCommand(
displayName: "SafeDIGenerateDependencyTree",
executable: toolLocation,
arguments: arguments,
environment: [:],
inputFiles: inputSwiftFiles,
outputFiles: [outputSwiftFile],
outputFiles: outputFiles,
),
]
}
Expand Down
61 changes: 50 additions & 11 deletions Plugins/SafeDIPrebuiltGenerator/SafeDIGenerateDependencyTree.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ struct SafeDIGenerateDependencyTree: BuildToolPlugin {
return []
}

let outputSwiftFile = context.pluginWorkDirectoryURL.appending(path: "SafeDI.swift")
let outputDirectory = context.pluginWorkDirectoryURL.appending(path: "SafeDIOutput")
// Swift Package Plugins did not (as of Swift 5.9) allow for
// creating dependencies between plugin output at the time of writing.
// Since our current build system did not support depending on the
Expand All @@ -46,19 +46,40 @@ struct SafeDIGenerateDependencyTree: BuildToolPlugin {
.sourceFiles(withSuffix: ".swift")
.map(\.url)
}

let allSwiftFiles = targetSwiftFiles + dependenciesSourceFiles
let rootFiles = findFilesWithRoots(in: allSwiftFiles)
guard !rootFiles.isEmpty else {
return []
}

let outputFiles = zip(rootFiles, outputFileNames(for: rootFiles)).map { _, name in
outputDirectory.appending(path: name)
}

let packageRoot = context.package.directoryURL
let inputSourcesFile = context.pluginWorkDirectoryURL.appending(path: "InputSwiftFiles.csv")
try (targetSwiftFiles.map { $0.path(percentEncoded: false) } + dependenciesSourceFiles.map { $0.path(percentEncoded: false) })
try allSwiftFiles
.map { relativePath(for: $0, relativeTo: packageRoot) }
.joined(separator: ",")
.write(
to: inputSourcesFile,
atomically: true,
encoding: .utf8,
)

let manifestFile = context.pluginWorkDirectoryURL.appending(path: "SafeDIManifest.json")
try writeManifest(
dependencyTreeInputFiles: rootFiles,
outputDirectory: outputDirectory,
to: manifestFile,
relativeTo: packageRoot,
)

let arguments = [
inputSourcesFile.path(percentEncoded: false),
"--dependency-tree-output",
outputSwiftFile.path(percentEncoded: false),
"--swift-manifest",
manifestFile.path(percentEncoded: false),
]

let downloadedToolLocation = context.downloadedToolLocation
Expand Down Expand Up @@ -94,8 +115,8 @@ struct SafeDIGenerateDependencyTree: BuildToolPlugin {
executable: toolLocation,
arguments: arguments,
environment: [:],
inputFiles: targetSwiftFiles + dependenciesSourceFiles,
outputFiles: [outputSwiftFile],
inputFiles: allSwiftFiles,
outputFiles: outputFiles,
),
]
}
Expand Down Expand Up @@ -152,21 +173,39 @@ extension Target {
return []
}

let outputSwiftFile = context.pluginWorkDirectoryURL.appending(path: "SafeDI.swift")
let rootFiles = findFilesWithRoots(in: inputSwiftFiles)
guard !rootFiles.isEmpty else {
return []
}

let outputDirectory = context.pluginWorkDirectoryURL.appending(path: "SafeDIOutput")
let outputFiles = zip(rootFiles, outputFileNames(for: rootFiles)).map { _, name in
outputDirectory.appending(path: name)
}

let projectRoot = context.xcodeProject.directoryURL
let inputSourcesFile = context.pluginWorkDirectoryURL.appending(path: "InputSwiftFiles.csv")
try inputSwiftFiles
.map { $0.path(percentEncoded: false) }
.map { relativePath(for: $0, relativeTo: projectRoot) }
.joined(separator: ",")
.write(
to: inputSourcesFile,
atomically: true,
encoding: .utf8,
)

let manifestFile = context.pluginWorkDirectoryURL.appending(path: "SafeDIManifest.json")
try writeManifest(
dependencyTreeInputFiles: rootFiles,
outputDirectory: outputDirectory,
to: manifestFile,
relativeTo: projectRoot,
)

let arguments = [
inputSourcesFile.path(percentEncoded: false),
"--dependency-tree-output",
outputSwiftFile.path(percentEncoded: false),
"--swift-manifest",
manifestFile.path(percentEncoded: false),
]

let downloadedToolLocation = context.downloadedToolLocation
Expand All @@ -188,7 +227,7 @@ extension Target {
arguments: arguments,
environment: [:],
inputFiles: inputSwiftFiles,
outputFiles: [outputSwiftFile],
outputFiles: outputFiles,
),
]
}
Expand Down
82 changes: 82 additions & 0 deletions Plugins/Shared.swift
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,85 @@ extension PackagePlugin.PluginContext {
return expectedToolLocation
}
}

/// Find Swift files that contain `@Instantiable(isRoot: true)` declarations.
func findFilesWithRoots(in swiftFiles: [URL]) -> [URL] {
swiftFiles.filter { fileURL in
guard let content = try? String(contentsOf: fileURL, encoding: .utf8) else { return false }
guard content.contains("isRoot") else { return false }
guard let regex = try? Regex(#"@Instantiable\s*\([^)]*isRoot\s*:\s*true[^)]*\)"#) else { return false }
// Check each match is not inside a comment or backtick-quoted code span.
for match in content.matches(of: regex) {
let lineStart = content[content.startIndex..<match.range.lowerBound].lastIndex(of: "\n").map { content.index(after: $0) } ?? content.startIndex
let linePrefix = content[lineStart..<match.range.lowerBound]
// Skip matches inside single-line comments.
if linePrefix.contains("//") { continue }
// Skip matches inside backtick-quoted code spans.
if linePrefix.contains("`") { continue }
return true
}
return false
}
}

/// Derive unique output filenames for a set of input Swift files.
/// If two files share the same base name (e.g. `ModuleA/Root.swift` and `ModuleB/Root.swift`),
/// parent directory components are prepended to disambiguate (e.g. `ModuleA_Root+SafeDI.swift`).
func outputFileNames(for inputURLs: [URL]) -> [String] {
let baseNames = inputURLs.map { $0.deletingPathExtension().lastPathComponent }

// Count occurrences of each base name.
var nameCounts = [String: Int]()
for name in baseNames {
nameCounts[name, default: 0] += 1
}

return zip(inputURLs, baseNames).map { url, baseName in
if nameCounts[baseName, default: 1] > 1 {
// Disambiguate by prepending the parent directory name.
let parent = url.deletingLastPathComponent().lastPathComponent
return "\(parent)_\(baseName)+SafeDI.swift"
} else {
return "\(baseName)+SafeDI.swift"
}
}
}

/// Compute a path string relative to a base directory, for use in the CSV and manifest.
/// Falls back to the absolute path if the URL is not under the base directory.
func relativePath(for url: URL, relativeTo base: URL) -> String {
let urlPath = url.path(percentEncoded: false)
let basePath = base.path(percentEncoded: false).hasSuffix("/")
? base.path(percentEncoded: false)
: base.path(percentEncoded: false) + "/"
if urlPath.hasPrefix(basePath) {
return String(urlPath.dropFirst(basePath.count))
}
return urlPath
}

/// Write a SafeDIToolManifest JSON file mapping input file paths to output file paths.
/// Input paths are written relative to `relativeTo` for remote cache compatibility.
/// Output paths are absolute since they reference the build system's plugin work directory.
///
/// Note: The JSON keys here must match the property names in `SafeDIToolManifest` and
/// `SafeDIToolManifest.InputOutputMap` (in SafeDICore). Plugins cannot import SafeDICore,
/// so these are duplicated as string literals.
func writeManifest(
dependencyTreeInputFiles: [URL],
outputDirectory: URL,
to manifestURL: URL,
relativeTo base: URL,
) throws {
let fileNames = outputFileNames(for: dependencyTreeInputFiles)
var entries = [[String: String]]()
for (inputURL, fileName) in zip(dependencyTreeInputFiles, fileNames) {
entries.append([
"inputFilePath": relativePath(for: inputURL, relativeTo: base),
"outputFilePath": outputDirectory.appending(path: fileName).path(percentEncoded: false),
])
}
let manifest = ["dependencyTreeGeneration": entries]
let data = try JSONSerialization.data(withJSONObject: manifest, options: [.sortedKeys])
try data.write(to: manifestURL)
}
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,31 @@ This plugin will:
3. If you have `.safedi/configuration/include.csv` or `.safedi/configuration/additionalImportedModules.csv`, create a `@SafeDIConfiguration` enum in your root module with the equivalent values and delete the CSV files
4. If you don't have CSV configuration files, create a `@SafeDIConfiguration`-decorated enum in your root module

### Migrating prebuild scripts or custom build system integrations

If you invoke `SafeDITool` directly (not via the provided SPM plugin), the `--dependency-tree-output` flag has been replaced with `--swift-manifest`. The tool now takes a JSON manifest file that maps input Swift files to output files. See [`SafeDIToolManifest`](Sources/SafeDICore/Models/SafeDIToolManifest.swift) for the expected format.

Before (1.x):
```bash
safedi-tool input.csv --dependency-tree-output ./generated/SafeDI.swift
```

After (2.x):
```bash
# Create a manifest mapping root files to outputs
cat > manifest.json << 'EOF'
{
"dependencyTreeGeneration": [
{
"inputFilePath": "Sources/App/Root.swift",
"outputFilePath": "generated/Root+SafeDI.swift"
}
]
}
EOF
safedi-tool input.csv --swift-manifest manifest.json
```

## Contributing

I’m glad you’re interested in SafeDI, and I’d love to see where you take it. Please review the [contributing guidelines](Contributing.md) prior to submitting a Pull Request.
Expand Down
Loading
Loading