Skip to content
Merged
14 changes: 14 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
root = true

[*]
indent_style = tab
tab_width = 2
trim_trailing_whitespace = true
insert_final_newline = true

[*.{py,yml}]
indent_style = space
indent_size = 2

[*.py]
indent_size = 4
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ jobs:
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
- name: Check incremental resource generation
run: python3 Tests/PluginIntegrationTests/test_resource_inputs.py
- name: Test
uses: capturecontext/swift-package-action/build@3.0-beta.14
with:
Expand All @@ -35,3 +37,9 @@ jobs:
action: xcodebuild-test
platform: ${{ matrix.platform }}
scheme: package-resources-cli-Package
- name: Report unfiltered dependency resolution failure
if: failure()
run: |
swift --version
swift package resolve
xcodebuild -resolvePackageDependencies -scheme package-resources-cli-Package -workspace .swiftpm/xcode/package.xcworkspace
12 changes: 11 additions & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,23 @@ let package = Package(
),
.package(
url: "https://github.com/capturecontext/swift-function-composition.git",
.upToNextMinor(from: "0.0.4"),
.upToNextMinor(from: "0.0.3"),
traits: ["NominalTypes"]
),
.package(
url: "https://github.com/capturecontext/swift-keypaths-extensions.git",
.upToNextMinor(from: "0.2.2")
),
// These merged revisions support checkout consumers on Swift 6.2 and 6.4.
// Replace with published version requirements before a semantic CLI release.
.package(
url: "https://github.com/capturecontext/swift-casification.git",
revision: "32a8de52e24cae336065925a0e3df2bfb2a9f010"
),
.package(
url: "https://github.com/capturecontext/swift-snippets.git",
revision: "aeb54d10efb0be0a2f3b5b70d3e88e2df0649281"
Comment thread
maximkrouk marked this conversation as resolved.
),
.package(
url: "https://github.com/capturecontext/swiftlang-snippets.git",
.upToNextMinor(from: "0.1.1")
Expand Down
70 changes: 70 additions & 0 deletions Plugins/package-resources-plugin/plugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,17 @@ struct PackageResourcesPlugin: BuildToolPlugin {
let configArgs: [String] = configURL.map { url in
["--config", url.path()]
} ?? []
var visitedDirectories: Set<URL> = []
var inputURLs = try resourceInputURLs(
in: target.directoryURL,
excluding: buildDirectory(containing: context.pluginWorkDirectoryURL),
visitedDirectories: &visitedDirectories
)
if let configURL, !inputURLs.contains(configURL) {
inputURLs.append(configURL)
}

inputURLs = Array(Set(inputURLs)).sorted { $0.path < $1.path }

return [
.buildCommand(
Expand All @@ -54,10 +64,70 @@ struct PackageResourcesPlugin: BuildToolPlugin {
+ inputArgs
+ outputArgs
+ configArgs,
inputFiles: inputURLs,
outputFiles: [
outputURL
]
)
]
}

/// SwiftPM writes this state at the active scratch root before evaluating plugins.
/// Locate it from our output path so custom scratch directory names are also excluded.
private func buildDirectory(containing pluginWorkDirectory: URL) -> URL {
var directory = pluginWorkDirectory.resolvingSymlinksInPath()
while directory.path != "/" {
let state = directory.appending(component: "workspace-state.json")
let plugins = directory.appending(component: "plugins")
if FileManager.default.fileExists(atPath: state.path),
FileManager.default.fileExists(atPath: plugins.path) {
return directory
}
directory.deleteLastPathComponent()
}
return pluginWorkDirectory.resolvingSymlinksInPath()
}

/// Files track edits; directories also track additions and removals between builds.
private func resourceInputURLs(
in directory: URL,
excluding pluginWorkDirectory: URL,
visitedDirectories: inout Set<URL>
) throws -> [URL] {
guard
![".build", ".git"].contains(directory.lastPathComponent),
directory.resolvingSymlinksInPath() != pluginWorkDirectory,
visitedDirectories.insert(directory.resolvingSymlinksInPath()).inserted
else { return [] }

var inputs = [directory]
let children = try FileManager.default.contentsOfDirectory(
at: directory,
includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey]
).sorted { $0.path() < $1.path() }
for child in children {
let values = try child.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey])
if values.isSymbolicLink == true {
let destination = child.resolvingSymlinksInPath()
guard FileManager.default.fileExists(atPath: destination.path),
destination != pluginWorkDirectory,
!destination.path.hasPrefix(pluginWorkDirectory.path + "/") else { continue }
inputs.append(child) // Retargeting the link also invalidates generation.
Comment thread
maximkrouk marked this conversation as resolved.
if let destinationValues = try? destination.resourceValues(forKeys: [.isDirectoryKey]) {
if destinationValues.isDirectory == true {
inputs += try resourceInputURLs(in: destination, excluding: pluginWorkDirectory,
visitedDirectories: &visitedDirectories)
} else {
inputs.append(destination)
}
}
} else if values.isDirectory == true {
inputs += try resourceInputURLs(in: child, excluding: pluginWorkDirectory,
visitedDirectories: &visitedDirectories)
} else {
inputs.append(child)
}
}
return inputs
}
}
138 changes: 138 additions & 0 deletions Tests/PluginIntegrationTests/test_resource_inputs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""Exercise the production plugin's incremental build inputs without remote dependencies.

The tiny tool records fixture contents instead of invoking resource processors, isolating
SwiftPM invalidation from generator behavior and dependency resolution.
"""

from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest


ROOT = Path(__file__).resolve().parents[2]


class ResourceInputsTests(unittest.TestCase):
def test_edits_additions_removals_and_package_configuration(self):
self.check_inputs()

def test_package_root_target_excludes_build_outputs(self):
self.check_inputs(target_root=True)

def test_package_root_target_excludes_custom_scratch_outputs(self):
self.check_inputs(target_root=True, scratch_path="build-output")

def test_symlinked_resource_directory_tracks_destination_edits(self):
self.check_inputs(symlink_resources=True)

def test_dangling_resource_symlink_is_ignored_until_replaced(self):
self.check_inputs(dangling_resource=True)

def check_inputs(self, target_root=False, scratch_path=".build", symlink_resources=False, dangling_resource=False):
with tempfile.TemporaryDirectory(prefix="package-resource-inputs-") as temporary:
root = Path(temporary)
plugin = root / "Plugins/Resources/plugin.swift"
plugin.parent.mkdir(parents=True)
shutil.copyfile(ROOT / "Plugins/package-resources-plugin/plugin.swift", plugin)
(root / "Package.swift").write_text('''// swift-tools-version: 6.1
import PackageDescription
let package = Package(
name: "ResourceInputFixture",
platforms: [.macOS(.v15)],
targets: [
.executableTarget(name: "package-resources-cli"),
.plugin(name: "Resources", capability: .buildTool(), dependencies: ["package-resources-cli"]),
.target(name: "Fixture", resources: [.copy("Resources")], plugins: ["Resources"]),
]
)
''')
if target_root:
manifest = root / "Package.swift"
manifest.write_text(manifest.read_text().replace(
'.target(name: "Fixture", resources: [.copy("Resources")],',
'.target(name: "Fixture", path: ".", '
'exclude: ["Plugins", "Sources/package-resources-cli", "Package.swift"], '
'resources: [.copy("Sources/Fixture/Resources")],',
))
if target_root and scratch_path != ".build":
# Keep SwiftPM source discovery valid; plugin input traversal must also exclude it.
manifest.write_text(manifest.read_text().replace(
'"Package.swift"]', f'"Package.swift", "{scratch_path}"]',
))
tool = root / "Sources/package-resources-cli/main.swift"
tool.parent.mkdir(parents=True)
tool.write_text(r'''import Foundation
let arguments = CommandLine.arguments
func argument(_ name: String) -> String { arguments[arguments.firstIndex(of: name)! + 1] }
let input = URL(fileURLWithPath: argument("--input"))
let rootResources = input.appending(path: "Sources/Fixture/Resources")
let resources = FileManager.default.fileExists(atPath: rootResources.path)
? rootResources : input.appending(path: "Resources")
let files = FileManager.default.enumerator(at: resources.resolvingSymlinksInPath(), includingPropertiesForKeys: nil, options: [.skipsHiddenFiles])!
var values: [String] = []
for case let file as URL in files where file.pathExtension == "txt" {
values.append(try String(contentsOf: file, encoding: .utf8))
}
if arguments.contains("--config") {
values.append(try String(contentsOfFile: argument("--config"), encoding: .utf8))
}
let snapshot = values.sorted().joined(separator: "|")
try "public let resourceSnapshot = \(String(reflecting: snapshot))\n"
.write(toFile: argument("--output"), atomically: true, encoding: .utf8)
''')
resources = root / "Sources/Fixture/Resources"
if symlink_resources:
shared = root / "SharedResources"
shared.mkdir()
resources.parent.mkdir(parents=True)
resources.symlink_to(shared, target_is_directory=True)
else:
resources.mkdir(parents=True)
(resources.parent / "Fixture.swift").write_text("public enum Fixture {}\n")
if dangling_resource:
dangling = resources / "missing.asset"
dangling.symlink_to(root / "missing-destination")
original = resources / "original.txt"
original.write_text("first")
configuration = root / ".packageresources"
configuration.write_text("config-first")

def build_snapshot():
result = subprocess.run(
["swift", "build", "--scratch-path", str(root / scratch_path), "--target", "Fixture", "--build-system", "native"],
cwd=root, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
)
self.assertEqual(result.returncode, 0, result.stdout)
generated = list((root / scratch_path / "plugins/outputs").rglob("Resources.generated.swift"))
self.assertEqual(len(generated), 1)
return generated[0].read_text()

self.assertIn('"config-first|first"', build_snapshot())
original.write_text("edited")
self.assertIn('"config-first|edited"', build_snapshot())
added = resources / "added.txt"
added.write_text("added")
self.assertIn('"added|config-first|edited"', build_snapshot())
original.unlink()
self.assertIn('"added|config-first"', build_snapshot())
configuration.write_text("config-edited")
self.assertIn('"added|config-edited"', build_snapshot())
if dangling_resource:
dangling.unlink()
dangling.mkdir()
(dangling / "recovered.txt").write_text("recovered")
self.assertIn('"added|config-edited|recovered"', build_snapshot())
if symlink_resources:
replacement = root / "ReplacementResources"
replacement.mkdir()
(replacement / "replacement.txt").write_text("replacement")
resources.unlink()
resources.symlink_to(replacement, target_is_directory=True)
self.assertIn('"config-edited|replacement"', build_snapshot())


if __name__ == "__main__":
unittest.main()