diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..5cfd51d --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2da04c9..5fc1589 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: @@ -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 diff --git a/Package.swift b/Package.swift index b8384b0..3215e01 100644 --- a/Package.swift +++ b/Package.swift @@ -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" + ), .package( url: "https://github.com/capturecontext/swiftlang-snippets.git", .upToNextMinor(from: "0.1.1") diff --git a/Plugins/package-resources-plugin/plugin.swift b/Plugins/package-resources-plugin/plugin.swift index bd15ae8..df640a3 100644 --- a/Plugins/package-resources-plugin/plugin.swift +++ b/Plugins/package-resources-plugin/plugin.swift @@ -44,7 +44,17 @@ struct PackageResourcesPlugin: BuildToolPlugin { let configArgs: [String] = configURL.map { url in ["--config", url.path()] } ?? [] + var visitedDirectories: Set = [] + 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( @@ -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 + ) 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. + 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 + } } diff --git a/Tests/PluginIntegrationTests/test_resource_inputs.py b/Tests/PluginIntegrationTests/test_resource_inputs.py new file mode 100644 index 0000000..395b505 --- /dev/null +++ b/Tests/PluginIntegrationTests/test_resource_inputs.py @@ -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()