Skip to content
Merged
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
a1b0e4f
Generate one output file per @Instantiable(isRoot: true) root
dfed Mar 30, 2026
2941c41
Replace --swift-output-directory with manifest-based --swift-manifest
dfed Mar 30, 2026
cddb24b
Add manifest validation tests and fix coverage
dfed Mar 30, 2026
4e613c0
Fix false positive in root detection regex
dfed Mar 30, 2026
5145810
Fix regex: use comment/backtick line prefix check instead of lookbehind
dfed Mar 30, 2026
e817070
Add tests for remaining uncovered code paths
dfed Mar 30, 2026
9142f5a
Eliminate uncovered nil branch in rootScopeGenerators
dfed Mar 31, 2026
ffc476a
Include sourceFilePath in Codable serialization
dfed Mar 31, 2026
40b64ed
Remove unused relativeTo parameter from writeManifest
dfed Mar 31, 2026
e37af92
Use relative input paths in CSV and manifest for cache compatibility
dfed Mar 31, 2026
7eee0e0
Change manifest from dictionary to array of InputOutputMap structs
dfed Mar 31, 2026
7e7220e
Revert simpleNameAndGenerics to internal visibility
dfed Mar 31, 2026
ba8c0c1
Fix duplicate file header when multiple roots share a source file
dfed Mar 31, 2026
e0dd37f
Revert DependencyTreeGenerator.imports to private
dfed Mar 31, 2026
999675e
Restore Sendable conformance on SafeDITool
dfed Mar 31, 2026
7d21f20
Add tests for skip-if-unchanged optimization and manifest+DOT coexist…
dfed Mar 31, 2026
17f2edf
Add comment noting JSON keys must match SafeDIToolManifest properties
dfed Mar 31, 2026
08d5b4e
Fix output filename collisions and non-deterministic multi-root ordering
dfed Mar 31, 2026
6fe9436
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