Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
37 changes: 23 additions & 14 deletions Documentation/Manual.md
Original file line number Diff line number Diff line change
Expand Up @@ -812,29 +812,38 @@ The `SafeDIPrebuiltGenerator` plugin has been removed in SafeDI 2.x. `SafeDIGene

### 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 `generate --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.
If you invoke `SafeDITool` directly (not via the provided SPM plugin), the 1.x `--dependency-tree-output` flag has been replaced. The simplest migration is `generate --combined-output`, which concatenates every dependency-tree, mock, and mock-configuration body SafeDITool would emit into one statically-known output file — a good fit for any build system that needs rule outputs declared up front.

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

After (2.x):
After (2.x), single module:
```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
safeditool generate input.csv --swift-manifest manifest.json
safeditool generate input.csv --combined-output ./generated/SafeDI.swift
```

For multi-module projects, you can add the `--module-info-output` flag so downstream modules can resolve `@Instantiable` types declared upstream without re-parsing the producer's sources. Each producer emits a `.safedi` sidecar; each consumer feeds the producer's `.safedi` back in through `--dependent-module-info-file-path`:

```bash
# Producing module (Subproject):
safeditool generate Subproject.csv \
--combined-output Subproject/SafeDIGenerated.swift \
--module-info-output Subproject.safedi

# Consuming module (App), with Subproject already built:
echo "Subproject.safedi" > deps.csv
Comment thread
dfed marked this conversation as resolved.
safeditool generate App.csv \
--combined-output App/SafeDIGenerated.swift \
--module-info-output App.safedi \
--dependent-module-info-file-path deps.csv
```

For working reference implementations, see the Bazel rule at [`bazel/safedi.bzl`](../bazel/safedi.bzl) and the Tuist plugin at [`TuistPlugins/SafeDITuist/ProjectDescriptionHelpers/SafeDI.swift`](../TuistPlugins/SafeDITuist/ProjectDescriptionHelpers/SafeDI.swift). Both invoke `generate --combined-output --module-info-output` per module and pass dependent modules' `.safedi` artifacts via `--dependent-module-info-file-path`.

If you need per-root output files (one generated `.swift` per `@Instantiable(isRoot: true)` type) instead of a single combined file, use `generate --swift-manifest` with a JSON manifest in the [`SafeDIToolManifest`](../Sources/SafeDICore/Models/SafeDIToolManifest.swift) format. `--swift-manifest` and `--combined-output` are mutually exclusive — pick whichever maps better onto your build system’s output model.

## Example applications

We’ve tied everything together with an example multi-user notes application backed by SwiftUI. You can compile and run this code in [an example single-module Xcode project](../Examples/ExampleProjectIntegration). This same multi-user notes app also exists in [an example multi-module Xcode project](../Examples/ExampleMultiProjectIntegration). We have also created [an example multi-module `Package.swift` that integrates with SafeDI](../Examples/Example Package Integration).
Expand Down
12 changes: 3 additions & 9 deletions Sources/SafeDITool/GenerateCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -481,8 +481,7 @@ struct Generate: AsyncParsableCommand {
var swiftFiles = try await findSwiftFiles(inDirectories: additionalDirectories)
if let swiftSourcesFilePath {
let sourcesFromFile = try String(contentsOfFile: swiftSourcesFilePath, encoding: .utf8)
.components(separatedBy: CharacterSet(arrayLiteral: ","))
.removingEmpty()
.parsedPathListCSV()
swiftFiles.formUnion(sourcesFromFile)
}
return swiftFiles
Expand Down Expand Up @@ -551,11 +550,7 @@ struct Generate: AsyncParsableCommand {
guard let currentCSVContent = try? String(contentsOfFile: swiftSourcesFilePath, encoding: .utf8) else {
return nil
}
let currentCSVPaths = Set(
currentCSVContent
.components(separatedBy: CharacterSet(arrayLiteral: ","))
.removingEmpty(),
)
let currentCSVPaths = Set(currentCSVContent.parsedPathListCSV())
guard currentCSVPaths == Set(cached.csvInputPaths) else {
return nil
}
Expand Down Expand Up @@ -602,8 +597,7 @@ struct Generate: AsyncParsableCommand {
if let dependentModuleInfoFilePath {
try .init(
String(contentsOfFile: dependentModuleInfoFilePath, encoding: .utf8)
.components(separatedBy: CharacterSet(arrayLiteral: ","))
.removingEmpty()
.parsedPathListCSV()
.map(\.asFileURL),
)
} else {
Expand Down
11 changes: 11 additions & 0 deletions Sources/SafeDITool/SafeDITool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,17 @@ extension String {
URL(filePath: self)
#endif
}

/// Parses a path-list CSV emitted by a build system, shell script, or
/// `echo`/`printf`. Splits on commas and newlines, trims surrounding
/// whitespace from each entry, and drops empty entries — so a trailing
/// `\n` (as `echo "x" > deps.csv` writes) or CRLF line endings don't
/// produce a path that includes whitespace.
func parsedPathListCSV() -> [String] {
components(separatedBy: CharacterSet(charactersIn: ",\n\r"))
.map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
}
}

protocol FileFinder: Sendable {
Expand Down
3 changes: 1 addition & 2 deletions Sources/SafeDITool/ScanCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,7 @@ func performScan(

// Read CSV and resolve file paths relative to project root.
let inputFilePaths = try String(contentsOfFile: inputSourcesFile, encoding: .utf8)
.components(separatedBy: CharacterSet(arrayLiteral: ","))
.removingEmpty()
.parsedPathListCSV()

let directoryBaseURL = projectRootURL.appendingPathComponent("", isDirectory: true)

Expand Down
66 changes: 66 additions & 0 deletions Tests/SafeDIToolTests/SafeDIToolCombinedOutputTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,72 @@ struct SafeDIToolCombinedOutputTests: ~Copyable {
#expect(mockConfig == emptyMockConfigurationFileOutput, "Unexpected mock configuration output: \(mockConfig)")
}

@Test
@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *)
mutating func run_combinedOutput_resolvesDependentModuleInfo_whenDepsCSVHasTrailingNewline() async throws {
// Build systems and shell scripts commonly produce the deps CSV with
// `echo "path.safedi" > deps.csv`, which writes a trailing newline.
// Generate must tolerate trailing/embedded whitespace and newlines in
// the CSV — otherwise the resolved file path includes "\n" and the
// `.safedi` load fails with a misleading file-not-found error.
let crossModuleOutput = try await executeSafeDIToolTest(
swiftFileContent: [
"""
@Instantiable
public struct CrossModuleService: Instantiable {
public init() {}
}
""",
],
filesToDelete: &filesToDelete,
)

let tempDir = FileManager.default.temporaryDirectory
.appendingPathComponent("SafeDIToolDepsCSVNewlineTest-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
filesToDelete.append(tempDir)

let rootURL = tempDir.appendingPathComponent("Root.swift")
try """
@Instantiable(isRoot: true)
public struct Root: Instantiable {
public init(crossModuleService: CrossModuleService) {
self.crossModuleService = crossModuleService
}
@Instantiated let crossModuleService: CrossModuleService
}
""".write(to: rootURL, atomically: true, encoding: .utf8)

let csvURL = tempDir.appendingPathComponent("sources.csv")
try rootURL.path.write(to: csvURL, atomically: true, encoding: .utf8)

let dependentModuleCSV = tempDir.appendingPathComponent("dependent-modules.csv")
// Emulate `echo "path.safedi" > deps.csv` — trailing newline included.
try "\(crossModuleOutput.moduleInfoOutputPath)\n".write(to: dependentModuleCSV, atomically: true, encoding: .utf8)

let combinedOutputURL = tempDir.appendingPathComponent("SafeDIGenerated.swift")
let tool = try Generate.parse([
csvURL.path,
"--combined-output", combinedOutputURL.path,
"--dependent-module-info-file-path", dependentModuleCSV.path,
])
try await tool.run()

let combinedOutput = try String(contentsOf: combinedOutputURL, encoding: .utf8)
#expect(combinedOutput == """
// This file was generated by the SafeDIGenerateDependencyTree build tool plugin.
// Any modifications made to this file will be overwritten on subsequent builds.
// Please refrain from editing this file directly.

extension Root {
public init() {
let crossModuleService = CrossModuleService()
self.init(crossModuleService: crossModuleService)
}
}
""", "Unexpected combined output: \(combinedOutput)")
}

@Test
@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *)
mutating func run_combinedOutput_emitsAdditionalMockFromConfiguration() async throws {
Expand Down
Loading