Skip to content
Open
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
109 changes: 109 additions & 0 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# PackageGeneratorPlugin — Copilot Instructions

## What This Project Is

A Swift Package Manager **Command Plugin** that auto-generates `Package.swift` for heavily modularized SPM projects. It reads source files, extracts imports via a companion binary (`package-generator-cli`), resolves dependencies, and writes the output.

It is **not a library**. There is no test target. Testing is done via dry-run in consumer projects.

## Build & Run

```bash
# Build the package (plugin + yaml-converter tool)
swift build

# Run the plugin in a consumer project
swift package plugin --allow-writing-to-package-directory package-generator

# With explicit config file
swift package plugin --allow-writing-to-package-directory package-generator --confFile myconfig.yaml

# Dry run (writes Package_generated.swift instead of Package.swift)
# Set dryRun: true in config, then:
swift package plugin --allow-writing-to-package-directory package-generator
diff Package.swift Package_generated.swift
```

## Architecture

### Targets

- **`Package Generator`** plugin — `Plugins/PackageGenerator/` — the SPM command plugin
- **`yaml-converter`** executable — `Tools/YamlConverter/` — converts YAML→JSON using Yams; invoked as a subprocess by the plugin
- **`package-generator-cli`** binary — ARM64-only artifact bundle from GitHub releases; does the actual Swift import extraction from source files

### Plugin Pipeline (`PackageGeneratorV2.swift` orchestrates)

| Phase | File | What it does |
|-------|------|-------------|
| Config load | `ConfigLoader.swift` | Finds `packageGenerator.{yaml,yml,json}`, converts YAML via `yaml-converter` subprocess, decodes into `ConfigurationV2` |
| 2 — Discovery | `TargetResolution.swift` | `resolveTargetPaths` (shortest-path logic), `linkTestTargets` (strips "Tests" suffix), `discoverExternalDeps` (reads SPM graph), `runCLI` (invokes `package-generator-cli`) |
| 3 — Import Analysis | `ImportAnalysis.swift` | `filterImports` — drops self-imports and explicit exclusions; Apple SDK filtering is intentionally **deferred** to render phase |
| 4 — Code Generation | `CodeGeneration.swift` | `renderSingleTarget`, `generateProductsSection`, `generateTargetsSection`, `writeOutput` |
| 5 — Advanced Features | `AdvancedFeatures.swift` | `generateExportedFiles`, `detectUnusedTargets`, `computeDependencyWeight` |

Entry point: `Plugin.swift` → `PackageGeneratorV2.generate(config:context:)`

### Configuration (`ConfigurationV2.swift`)

Config is decoded from JSON (YAML is converted first). The decoder merges the legacy `targetsParameters` dict into inline `parameters` on each target **in memory only** — the on-disk config file is never modified.

## Key Conventions

### PackagePlugin API Usage
- Always use `context.package.directoryURL` (not the deprecated `directory`)
- Always use `context.pluginWorkDirectoryURL` for temp files
- Always use `URL`-based APIs with `appendingPathComponent` / `deletingLastPathComponent`

### External Dependency Identity
Use `dependency.package.id` (URL-derived identity, e.g. `"vitamin-play-apple-releases"`) **not** `dependency.package.displayName` (self-declared name, may differ). This is what SPM requires in `.product(name:package:)`.

```swift
// Correct
let packageIdentity = dependency.package.id

// Wrong — displayName can differ from the URL-derived identity
let packageIdentity = dependency.package.displayName
```

### Apple SDK Filtering is Deferred
`filterImports` (Phase 3) does **not** filter Apple SDKs. This is intentional: filtering happens in `renderSingleTarget` (Phase 4), after checking `externalDeps` first. This prevents silently dropping external products whose names collide with Apple SDK names (e.g., `Charts`).

### Parameter Injection is Verbatim
`parameters` strings and `additionalDependencies` strings from config are written directly into `Package.swift` without parsing or validation.

`parameters` are appended after `path:` as extra target arguments:
```swift
for param in target.parameters ?? [] {
extraParams += ",\n\(s2)\(param.trimmingCharacters(in: .whitespacesAndNewlines))"
}
```

`additionalDependencies` are appended to the `dependencies:` array after the auto-resolved imports (which are sorted). Use when a target needs a dep that isn't imported in source (macros, plugins, transitive deps):
```yaml
additionalDependencies:
- '"SomeLocalTarget"'
- '.product(name: "PreviewSnapshots", package: "swiftui-preview-snapshots")'
```

### Dependency Resolution Priority (in `renderSingleTarget`)
1. `externalDeps` map → `.product(name:package:)` reference
2. `allLocalTargetNames` set → `"TargetName"` string literal
3. Apple SDK built-in list (`AppleSDKs.swift`) → silently skipped
4. Otherwise → warning emitted, dep skipped
5. `additionalDependencies` → appended verbatim **after** the sorted resolved deps, bypassing all resolution

### Swift 6 Strict Concurrency
The package uses `swiftLanguageModes: [.v6]`. All new code must comply with Swift 6 strict concurrency rules.

### Code Structure
Top-level functions at file scope (not in types) is the pattern for all phase functions. `PackageGeneratorV2` is the only struct, with `static func generate`.

### YAML Temp Files
YAML configs are converted to a temp file named `.packageGenerator_temp_<UUID>.json` in the package directory. Cleaned up automatically unless `keepTempFiles: true`.

### Config Search Order
`--confFile` arg → `packageGenerator.yaml` → `packageGenerator.yml` → `packageGenerator.json`

### Binary Target
`package-generator-cli` is ARM64-only (`arm64-apple-macosx`). To test against a local build, swap the `.binaryTarget` in `Package.swift` to use a local `path:` (the commented-out block is already there).
10 changes: 6 additions & 4 deletions Plugins/PackageGenerator/CodeGeneration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ func renderSingleTarget(

// Build multi-line dependencies section using three-way resolution.
var depsStr = ""
if !target.dependencies.isEmpty {
let extraDeps = target.additionalDependencies ?? []
if !target.dependencies.isEmpty || !extraDeps.isEmpty {
var resolvedLines: [String] = []
for dep in target.dependencies {
if let extDep = externalDeps[dep] {
Expand All @@ -74,9 +75,10 @@ func renderSingleTarget(
Diagnostics.emit(.warning, "Dropped unresolved import '\(dep)'. If it's a system framework, ignore this.")
}
}
if !resolvedLines.isEmpty {
let lines = resolvedLines.sorted().map { "\(s3)\($0)" }
depsStr = "\n\(s2)dependencies: [\n" + lines.joined(separator: ",\n") + "\n\(s2)],"
let allLines = resolvedLines.sorted().map { "\(s3)\($0)" }
+ extraDeps.map { "\(s3)\($0)" }
if !allLines.isEmpty {
depsStr = "\n\(s2)dependencies: [\n" + allLines.joined(separator: ",\n") + "\n\(s2)],"
}
}

Expand Down
7 changes: 6 additions & 1 deletion Plugins/PackageGenerator/ConfigurationV2.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ struct ConfigurationV2: Codable {
let exclude: [String]?
let parameters: [String]?
let regularTargetName: String?
/// Verbatim dependency strings injected into the target's `dependencies:` array as-is.
/// Accepts plain target names (`"Core"`) or full product references (`.product(name: "Foo", package: "bar")`).
let additionalDependencies: [String]?

enum TargetType: String, Codable {
case regular
Expand All @@ -98,13 +101,14 @@ struct ConfigurationV2: Codable {
}
}

init(name: String, type: TargetType = .regular, path: String? = nil, exclude: [String]? = nil, parameters: [String]? = nil, regularTargetName: String? = nil) {
init(name: String, type: TargetType = .regular, path: String? = nil, exclude: [String]? = nil, parameters: [String]? = nil, regularTargetName: String? = nil, additionalDependencies: [String]? = nil) {
self.name = name
self.type = type
self.path = path
self.exclude = exclude
self.parameters = parameters
self.regularTargetName = regularTargetName
self.additionalDependencies = additionalDependencies
}

init(from decoder: Decoder) throws {
Expand All @@ -115,6 +119,7 @@ struct ConfigurationV2: Codable {
exclude = try c.decodeIfPresent([String].self, forKey: .exclude)
parameters = try c.decodeIfPresent([String].self, forKey: .parameters)
regularTargetName = try c.decodeIfPresent(String.self, forKey: .regularTargetName)
additionalDependencies = try c.decodeIfPresent([String].self, forKey: .additionalDependencies)
}
}

Expand Down
3 changes: 2 additions & 1 deletion Plugins/PackageGenerator/PackageGeneratorV2.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ struct PackageGeneratorV2 {
path: resolvedPath,
fullPath: context.package.directoryURL.appendingPathComponent(resolvedPath).path,
exclude: targetSpec.exclude ?? [],
parameters: targetSpec.parameters
parameters: targetSpec.parameters,
additionalDependencies: targetSpec.additionalDependencies
)

parsedPackages.append(parsed)
Expand Down
6 changes: 5 additions & 1 deletion Plugins/PackageGenerator/ParsedPackage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ public struct ParsedPackage: Codable, CustomStringConvertible {
public var hasBiggestNumberOfDependencies: Bool = false
/// Extra target parameters (resources:, swiftSettings:, etc.) rendered verbatim after `path:`.
public var parameters: [String]?
/// Verbatim dependency strings injected into the `dependencies:` array as-is, bypassing import resolution.
public var additionalDependencies: [String]?

enum CodingKeys: String, CodingKey {
case name
Expand Down Expand Up @@ -40,6 +42,7 @@ public struct ParsedPackage: Codable, CustomStringConvertible {
self.localDependencies = try container.decodeIfPresent(Int.self, forKey: .localDependencies) ?? 0
self.hasBiggestNumberOfDependencies = try container.decodeIfPresent(Bool.self, forKey: .hasBiggestNumberOfDependencies) ?? false
self.parameters = nil // Set by plugin from config; not from CLI JSON
self.additionalDependencies = nil // Set by plugin from config; not from CLI JSON
}

public var hasResources: Bool {
Expand All @@ -50,7 +53,7 @@ public struct ParsedPackage: Codable, CustomStringConvertible {
return "[\(dependencies.count)|\(localDependencies)] \(name) \(hasResources == false ? "" : "/ hasResources")"
}

public init(name: String, isTest: Bool, isMacro: Bool = false, dependencies: [String], path: String, fullPath: String, resources: String? = nil, localDependencies: Int = 0, hasBiggestNumberOfDependencies: Bool = false, exclude: [String] = [], parameters: [String]? = nil) {
public init(name: String, isTest: Bool, isMacro: Bool = false, dependencies: [String], path: String, fullPath: String, resources: String? = nil, localDependencies: Int = 0, hasBiggestNumberOfDependencies: Bool = false, exclude: [String] = [], parameters: [String]? = nil, additionalDependencies: [String]? = nil) {
self.name = name
self.isTest = isTest
self.isMacro = isMacro
Expand All @@ -62,5 +65,6 @@ public struct ParsedPackage: Codable, CustomStringConvertible {
self.hasBiggestNumberOfDependencies = hasBiggestNumberOfDependencies
self.exclude = exclude
self.parameters = parameters
self.additionalDependencies = additionalDependencies
}
}
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,13 @@ packageDirectoryTargets:
- 'swiftSettings: [...]'
- 'resources: [.process("Files")]'
regularTargetName: null # for tests: explicit link to regular target
additionalDependencies: # verbatim entries added to dependencies: []
- '"AnotherTarget"'
- '.product(name: "Foo", package: "foo-package")'
```

`additionalDependencies` injects strings verbatim into the target's `dependencies:` array, bypassing import analysis. Use it when a target needs a dependency that isn't imported in source (common with macros, plugins, or transitive requirements). Auto-resolved imports are emitted first (sorted), then `additionalDependencies` in declaration order.

**Path Resolution** (shortest-path logic):
- Regular target: `<path>/Sources/<name>` or custom `path`
- Test target: `<path>/Tests/<name>` or custom `path`
Expand Down