diff --git a/docs/bazel.md b/docs/bazel.md index fcdfa4e885..40c48d581b 100755 --- a/docs/bazel.md +++ b/docs/bazel.md @@ -963,3 +963,59 @@ xcodeproj_extra_files( | files | The list of extra files to surface in the Xcode navigator. | List of labels | optional | `[]` | + + +## xcodeproj_synchronized_folders + +
+load("@rules_xcodeproj//xcodeproj/internal/docs:aspect_hints.bzl", "xcodeproj_synchronized_folders")
+
+xcodeproj_synchronized_folders(name, folders)
+
+ +This rule is used to show a target's source folders in Xcode as synchronized +folders (Xcode 16's buildable folders), whose contents Xcode keeps up to date on +its own: files added to such a folder appear in the target without regenerating +the project. The provider created by this rule should be attached to the +related target via an aspect hint. + +The folders are relative to the package of the target that the hint is attached +to, so one hint can be shared by targets in different packages that use the +same layout. The target's sources inside the folders are shown by the folders +instead of individually. + +Bazel still only compiles the target's `srcs`, so they should be every source +file in the folders, typically with a `glob`; otherwise a file added in Xcode +appears in the target but isn't built. + +**EXAMPLE** + +```starlark +load( + "@rules_xcodeproj//xcodeproj:xcodeproj_synchronized_folders.bzl", + "xcodeproj_synchronized_folders", +) + +swift_library( + ... + srcs = glob(["Sources/**/*.swift"]), + aspect_hints = [":sources_folder"], + ... +) + +# Show `Sources` as a synchronized folder of the Swift library in Xcode +xcodeproj_synchronized_folders( + name = "sources_folder", + folders = ["Sources"], +) +``` + +**ATTRIBUTES** + + +| Name | Description | Type | Mandatory | Default | +| :------------- | :------------- | :------------- | :------------- | :------------- | +| name | A unique name for this target. | Name | required | | +| folders | The folders to show as synchronized folders, relative to the package of the target that the hint is attached to. | List of strings | required | | + + diff --git a/test/BUILD b/test/BUILD index a82a284563..388d7f1b37 100644 --- a/test/BUILD +++ b/test/BUILD @@ -13,6 +13,7 @@ test_suite( "//test/internal/target_id", "//test/internal/targets", "//test/internal/xcodeproj_extra_files", + "//test/internal/xcodeproj_synchronized_folders", "//test/internal/xcschemes", ], ) @@ -30,6 +31,7 @@ bzl_library( "//test/internal/target_id:bzls", "//test/internal/targets:bzls", "//test/internal/xcodeproj_extra_files:bzls", + "//test/internal/xcodeproj_synchronized_folders:bzls", "//test/internal/xcschemes:bzls", ], ) diff --git a/test/internal/pbxproj_partials/write_files_and_groups_tests.bzl b/test/internal/pbxproj_partials/write_files_and_groups_tests.bzl index c82c163284..6b0b22d8b4 100644 --- a/test/internal/pbxproj_partials/write_files_and_groups_tests.bzl +++ b/test/internal/pbxproj_partials/write_files_and_groups_tests.bzl @@ -21,6 +21,9 @@ _FILE_PATHS_FILE = mock_actions.mock_file( _GENERATED_FILE_PATHS_FILE = mock_actions.mock_file( "a_generator_name_pbxproj_partials/generated_file_paths_file", ) +_SYNCHRONIZED_FOLDERS_FILE = mock_actions.mock_file( + "a_generator_name_pbxproj_partials/synchronized_folders_file", +) def _write_files_and_groups_test_impl(ctx): env = unittest.begin(ctx) @@ -35,12 +38,14 @@ def _write_files_and_groups_test_impl(ctx): _RESOLVED_REPOSITORIES_FILE_DECLARED_FILE: None, _FILE_PATHS_FILE: None, _GENERATED_FILE_PATHS_FILE: None, + _SYNCHRONIZED_FOLDERS_FILE: None, } expected_inputs = [ _FILE_PATHS_FILE, _GENERATED_FILE_PATHS_FILE, ctx.attr.execution_root_file, ctx.attr.selected_model_versions_file, + _SYNCHRONIZED_FOLDERS_FILE, ] + ctx.attr.buildfile_subidentifiers_files expected_outputs = [ _FILES_AND_GROUPS_DECLARED_FILE, @@ -91,6 +96,7 @@ def _write_files_and_groups_test_impl(ctx): install_path = ctx.attr.install_path, project_options = ctx.attr.project_options, selected_model_versions_file = ctx.attr.selected_model_versions_file, + synchronized_folders = depset(ctx.attr.synchronized_folders), tool = None, workspace_directory = ctx.attr.workspace_directory, ) @@ -190,6 +196,7 @@ write_files_and_groups_test = unittest.make( "install_path": attr.string(mandatory = True), "project_options": attr.string_dict(mandatory = True), "selected_model_versions_file": attr.string(mandatory = True), + "synchronized_folders": attr.string_list(), "workspace_directory": attr.string(mandatory = True), # Expected @@ -223,6 +230,7 @@ def write_files_and_groups_test_suite(name): install_path, project_options, selected_model_versions_file, + synchronized_folders = [], workspace_directory, # Expected @@ -244,6 +252,7 @@ def write_files_and_groups_test_suite(name): install_path = install_path, project_options = project_options, selected_model_versions_file = selected_model_versions_file, + synchronized_folders = synchronized_folders, workspace_directory = workspace_directory, # Expected @@ -300,6 +309,9 @@ def write_files_and_groups_test_suite(name): _GENERATED_FILE_PATHS_FILE.path, # developmentRegion "en", + # synchronizedFoldersFile + "--synchronized-folders-file", + _SYNCHRONIZED_FOLDERS_FILE.path, # useBaseInternationalization "--use-base-internationalization", # buildFileSubIdentifiersFiles @@ -310,6 +322,7 @@ def write_files_and_groups_test_suite(name): expected_writes = { _FILE_PATHS_FILE: "\n", _GENERATED_FILE_PATHS_FILE: "\n", + _SYNCHRONIZED_FOLDERS_FILE: "\n", }, ) @@ -325,6 +338,7 @@ def write_files_and_groups_test_suite(name): ], colorize = True, compile_stub_needed = True, + synchronized_folders = ["a/path/to/Sources"], execution_root_file = "an/execution/root/file", files = [ "a/path/to/a/file", @@ -384,6 +398,9 @@ def write_files_and_groups_test_suite(name): _GENERATED_FILE_PATHS_FILE.path, # developmentRegion "enGB", + # synchronizedFoldersFile + "--synchronized-folders-file", + _SYNCHRONIZED_FOLDERS_FILE.path, # useBaseInternationalization "--use-base-internationalization", # compileStubNeeded @@ -401,6 +418,10 @@ a/path/to/a/file another/path/to/another/file a/path/to/a/file_path.bundle another/path/to/another/file_path.framework +a/path/to/Sources +""", + _SYNCHRONIZED_FOLDERS_FILE: """\ +a/path/to/Sources """, _GENERATED_FILE_PATHS_FILE: """\ file diff --git a/test/internal/xcodeproj_synchronized_folders/BUILD b/test/internal/xcodeproj_synchronized_folders/BUILD new file mode 100644 index 0000000000..647a57dfa3 --- /dev/null +++ b/test/internal/xcodeproj_synchronized_folders/BUILD @@ -0,0 +1,15 @@ +load("@bazel_skylib//:bzl_library.bzl", "bzl_library") +load(":xcodeproj_synchronized_folders_tests.bzl", "xcodeproj_synchronized_folders_test_suite") + +xcodeproj_synchronized_folders_test_suite(name = "xcodeproj_synchronized_folders_test") + +test_suite(name = "xcodeproj_synchronized_folders") + +bzl_library( + name = "bzls", + srcs = glob( + ["*.bzl"], + exclude = ["utils.bzl"], + ), + visibility = ["//test:__pkg__"], +) diff --git a/test/internal/xcodeproj_synchronized_folders/xcodeproj_synchronized_folders_tests.bzl b/test/internal/xcodeproj_synchronized_folders/xcodeproj_synchronized_folders_tests.bzl new file mode 100644 index 0000000000..6ae56877cf --- /dev/null +++ b/test/internal/xcodeproj_synchronized_folders/xcodeproj_synchronized_folders_tests.bzl @@ -0,0 +1,74 @@ +"""Tests for the `xcodeproj_synchronized_folders` module.""" + +load("@bazel_skylib//lib:unittest.bzl", "analysistest", "asserts") +load( + "//xcodeproj:xcodeproj_synchronized_folders.bzl", + "xcodeproj_synchronized_folders", +) + +# buildifier: disable=bzl-visibility +load( + "//xcodeproj/internal:providers.bzl", + "XcodeProjSynchronizedFoldersHintInfo", +) + +def _provider_contents_test_impl(ctx): + env = analysistest.begin(ctx) + + target_under_test = analysistest.target_under_test(env) + + asserts.equals( + env, + ("Sources", "Tests/Unit"), + target_under_test[XcodeProjSynchronizedFoldersHintInfo].folders, + ) + + return analysistest.end(env) + +provider_contents_test = analysistest.make(_provider_contents_test_impl) + +def _invalid_folder_test_impl(ctx): + env = analysistest.begin(ctx) + asserts.expect_failure(env, "must be a path relative to a package") + return analysistest.end(env) + +invalid_folder_test = analysistest.make( + _invalid_folder_test_impl, + expect_failure = True, +) + +def _test_provider_contents(): + xcodeproj_synchronized_folders( + name = "xcodeproj_synchronized_folders_subject", + folders = ["Sources", "Tests/Unit"], + tags = ["manual"], + ) + + provider_contents_test( + name = "provider_contents_test", + target_under_test = ":xcodeproj_synchronized_folders_subject", + ) + +def _test_invalid_folder(): + xcodeproj_synchronized_folders( + name = "xcodeproj_synchronized_folders_invalid_subject", + folders = ["../Sources"], + tags = ["manual"], + ) + + invalid_folder_test( + name = "invalid_folder_test", + target_under_test = ":xcodeproj_synchronized_folders_invalid_subject", + ) + +def xcodeproj_synchronized_folders_test_suite(name): + _test_provider_contents() + _test_invalid_folder() + + native.test_suite( + name = name, + tests = [ + ":invalid_folder_test", + ":provider_contents_test", + ], + ) diff --git a/tools/generators/files_and_groups/src/ElementCreator/CreateFileElement.swift b/tools/generators/files_and_groups/src/ElementCreator/CreateFileElement.swift index 6f74eee377..147ed0056a 100644 --- a/tools/generators/files_and_groups/src/ElementCreator/CreateFileElement.swift +++ b/tools/generators/files_and_groups/src/ElementCreator/CreateFileElement.swift @@ -4,24 +4,31 @@ extension ElementCreator { struct CreateFileElement { private let createAttributes: CreateAttributes private let createIdentifier: CreateIdentifier + private let synchronizedFolders: Set private let callable: Callable /// - Parameters: + /// - synchronizedFolders: The paths of folders to create as + /// `PBXFileSystemSynchronizedRootGroup`s. /// - callable: The function that will be called in /// `callAsFunction()`. init( createAttributes: CreateAttributes, createIdentifier: CreateIdentifier, + synchronizedFolders: Set = [], callable: @escaping Callable ) { self.createAttributes = createAttributes self.createIdentifier = createIdentifier + self.synchronizedFolders = synchronizedFolders self.callable = callable } - /// Creates a `PBXFileReference` element. + /// Creates a `PBXFileReference` element, or a + /// `PBXFileSystemSynchronizedRootGroup` element for a synchronized + /// folder. func callAsFunction( name: String, ext: String?, @@ -31,6 +38,15 @@ extension ElementCreator { element: Element, resolvedRepository: ResolvedRepository? ) { + if synchronizedFolders.contains(bazelPath) { + return Self.synchronizedRootGroup( + name: name, + bazelPath: bazelPath, + bazelPathType: bazelPathType, + createAttributes: createAttributes + ) + } + return callable( /*name:*/ name, /*ext:*/ ext, @@ -118,6 +134,59 @@ sourceTree = \(attributes.elementAttributes.sourceTree.rawValue); } ) } + /// Creates a `PBXFileSystemSynchronizedRootGroup` element, which Xcode + /// fills with the contents of the folder at `bazelPath`. + static func synchronizedRootGroup( + name: String, + bazelPath: BazelPath, + bazelPathType: BazelPathType, + createAttributes: ElementCreator.CreateAttributes + ) -> ( + element: Element, + resolvedRepository: ResolvedRepository? + ) { + let attributes = createAttributes( + name: name, + bazelPath: bazelPath, + bazelPathType: bazelPathType, + isGroup: true + ) + + let nameAttribute: String + if let name = attributes.elementAttributes.name { + nameAttribute = "name = \(name.pbxProjEscaped); " + } else { + nameAttribute = "" + } + // Xcode adds the empty `explicitFileTypes` and `explicitFolders` when + // the folder's contents change, rewriting the whole project, unless + // they are already present. + let content = """ +{isa = PBXFileSystemSynchronizedRootGroup; \ +explicitFileTypes = {}; explicitFolders = (); \ +\(nameAttribute)\ +path = \(attributes.elementAttributes.path.pbxProjEscaped); \ +sourceTree = \(attributes.elementAttributes.sourceTree.rawValue); } +""" + + return ( + element: .init( + name: name, + object: .init( + identifier: Identifiers.FilesAndGroups + .synchronizedRootGroup( + bazelPath.path, + name: attributes.elementAttributes.name ?? + attributes.elementAttributes.path + ), + content: content + ), + sortOrder: .groupLike + ), + resolvedRepository: attributes.resolvedRepository + ) + } + private static func calculateFileTypeType( basename: String, extension: String? diff --git a/tools/generators/files_and_groups/src/ElementCreator/ElementCreator.swift b/tools/generators/files_and_groups/src/ElementCreator/ElementCreator.swift index 3bb20eb870..62de1bbc78 100644 --- a/tools/generators/files_and_groups/src/ElementCreator/ElementCreator.swift +++ b/tools/generators/files_and_groups/src/ElementCreator/ElementCreator.swift @@ -1,3 +1,4 @@ +import Foundation import PBXProj struct ElementCreator { @@ -23,6 +24,9 @@ struct ElementCreator { ), includeCompileStub: compileStubNeeded, installPath: arguments.installPath, + synchronizedFolders: try readSynchronizedFoldersFile( + arguments.synchronizedFoldersFile + ), selectedModelVersions: try environment.readSelectedModelVersionsFile( arguments.selectedModelVersionsFile @@ -54,6 +58,45 @@ struct ElementCreator { } } +/// Reads the paths of synchronized folders, one per line, from `url`. +func readSynchronizedFoldersFile(_ url: URL?) throws -> Set { + guard let url else { + return [] + } + return Set( + try String(contentsOf: url, encoding: .utf8) + .split(separator: "\n") + .map { BazelPath(String($0)) } + ) +} + +/// Removes the paths inside synchronized folders, whose contents Xcode +/// shows on its own. +func removingPathsInSynchronizedFolders( + _ paths: [BazelPath], + synchronizedFolders: Set +) -> [BazelPath] { + guard !synchronizedFolders.isEmpty else { + return paths + } + return paths.filter { path in + var components = path.path.split(separator: "/") + guard !components.isEmpty else { + return true + } + components.removeLast() + while !components.isEmpty { + if synchronizedFolders.contains( + BazelPath(components.joined(separator: "/")) + ) { + return false + } + components.removeLast() + } + return true + } +} + struct CreatedElements { let partial: String let bazelPathAndIdentifiers: [(BazelPath, String)] diff --git a/tools/generators/files_and_groups/src/ElementCreator/ElementCreatorArguments.swift b/tools/generators/files_and_groups/src/ElementCreator/ElementCreatorArguments.swift index 7aa31e6f97..fda47c0575 100644 --- a/tools/generators/files_and_groups/src/ElementCreator/ElementCreatorArguments.swift +++ b/tools/generators/files_and_groups/src/ElementCreator/ElementCreatorArguments.swift @@ -41,5 +41,15 @@ Path to a file that contains a JSON representation of \ transform: { $0 == "" ? nil : $0 == "1" } ) var usesTabs: Bool? + + @Option( + help: """ +Path to a file that contains the paths, relative to the Bazel execution root, \ +of folders to create as `PBXFileSystemSynchronizedRootGroup`s instead of \ +`PBXFileReference`s. +""", + transform: { URL(fileURLWithPath: $0, isDirectory: false) } + ) + var synchronizedFoldersFile: URL? } } diff --git a/tools/generators/files_and_groups/src/ElementCreator/ElementCreatorEnvironment.swift b/tools/generators/files_and_groups/src/ElementCreator/ElementCreatorEnvironment.swift index 612a9c5e18..6aa4152a34 100644 --- a/tools/generators/files_and_groups/src/ElementCreator/ElementCreatorEnvironment.swift +++ b/tools/generators/files_and_groups/src/ElementCreator/ElementCreatorEnvironment.swift @@ -99,6 +99,7 @@ extension ElementCreator.Environment { externalDir: String, includeCompileStub: Bool, installPath: String, + synchronizedFolders: Set = [], selectedModelVersions: [BazelPath: String], workspace: String ) -> ElementCreator.CreateRootElements { @@ -113,6 +114,7 @@ extension ElementCreator.Environment { let createFileElement = ElementCreator.CreateFileElement( createAttributes: createAttributes, createIdentifier: createIdentifier, + synchronizedFolders: synchronizedFolders, callable: createFileElementCallable ) let createFile = ElementCreator.CreateFile( diff --git a/tools/generators/files_and_groups/src/Generator/Generator.swift b/tools/generators/files_and_groups/src/Generator/Generator.swift index 073a40a04a..6bdfe11365 100644 --- a/tools/generators/files_and_groups/src/Generator/Generator.swift +++ b/tools/generators/files_and_groups/src/Generator/Generator.swift @@ -21,8 +21,12 @@ struct Generator { func generate(arguments: Arguments) async throws { // FIXME: Do these in parallel as tasks let pathTree = try await environment.calculatePathTree( - /*paths:*/ + /*paths:*/ removingPathsInSynchronizedFolders( environment.readFilePathsFile(arguments.filePathsFile), + synchronizedFolders: readSynchronizedFoldersFile( + arguments.elementCreatorArguments.synchronizedFoldersFile + ) + ), /*generatedPaths:*/ environment.readGeneratedFilePathsFile( arguments.generatedFilePathsFile ) diff --git a/tools/generators/files_and_groups/test/ElementCreator/SynchronizedFoldersTests.swift b/tools/generators/files_and_groups/test/ElementCreator/SynchronizedFoldersTests.swift new file mode 100644 index 0000000000..77cad82868 --- /dev/null +++ b/tools/generators/files_and_groups/test/ElementCreator/SynchronizedFoldersTests.swift @@ -0,0 +1,132 @@ +import CustomDump +import PBXProj +import XCTest + +@testable import files_and_groups + +final class SynchronizedFoldersTests: XCTestCase { + + // MARK: - CreateFileElement + + func test_createFileElement_synchronizedFolder() { + // Arrange + + let createAttributes = ElementCreator.CreateAttributes.mock( + elementAttributes: .init( + sourceTree: .group, + name: nil, + path: "Sources" + ), + resolvedRepository: nil + ) + let createFileElement = ElementCreator.CreateFileElement( + createAttributes: createAttributes.mock, + createIdentifier: ElementCreator.Stubs.createIdentifier, + synchronizedFolders: ["a/Sources"], + callable: { _, _, _, _, _, _ in + XCTFail("Synchronized folders don't create a file reference") + fatalError() + } + ) + + let expectedElement = Element( + name: "Sources", + object: .init( + identifier: Identifiers.FilesAndGroups + .synchronizedRootGroup("a/Sources", name: "Sources"), + content: """ +{isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; \ +explicitFolders = (); path = Sources; sourceTree = ""; } +""" + ), + sortOrder: .groupLike + ) + + // Act + + let result = createFileElement( + name: "Sources", + ext: nil, + bazelPath: "a/Sources", + bazelPathType: .workspace + ) + + // Assert + + XCTAssertNoDifference(result.element, expectedElement) + } + + func test_createFileElement_otherPathsUseCallable() { + // Arrange + + let stubbedElement = Element( + name: "a.swift", + object: .init(identifier: "FILE_ID", content: "FILE_CONTENT"), + sortOrder: .fileLike + ) + let createFileElement = ElementCreator.CreateFileElement( + createAttributes: ElementCreator.Stubs.createAttributes, + createIdentifier: ElementCreator.Stubs.createIdentifier, + synchronizedFolders: ["a/Sources"], + callable: { _, _, _, _, _, _ in + return (element: stubbedElement, resolvedRepository: nil) + } + ) + + // Act + + let result = createFileElement( + name: "a.swift", + ext: "swift", + bazelPath: "a/Other/a.swift", + bazelPathType: .workspace + ) + + // Assert + + XCTAssertNoDifference(result.element, stubbedElement) + } + + // MARK: - removingPathsInSynchronizedFolders + + func test_removingPathsInSynchronizedFolders() { + // Arrange + + let paths: [BazelPath] = [ + "a/BUILD", + "a/Sources", + "a/Sources/A.swift", + "a/Sources/Nested/B.swift", + "a/SourcesOther/C.swift", + "b/Sources/D.swift", + "", + ] + let expectedPaths: [BazelPath] = [ + "a/BUILD", + "a/Sources", + "a/SourcesOther/C.swift", + "b/Sources/D.swift", + "", + ] + + // Act + + let result = removingPathsInSynchronizedFolders( + paths, + synchronizedFolders: ["a/Sources"] + ) + + // Assert + + XCTAssertNoDifference(result, expectedPaths) + } + + func test_removingPathsInSynchronizedFolders_noFolders() { + let paths: [BazelPath] = ["a/Sources/A.swift"] + + XCTAssertNoDifference( + removingPathsInSynchronizedFolders(paths, synchronizedFolders: []), + paths + ) + } +} diff --git a/tools/generators/lib/PBXProj/src/Identifiers.swift b/tools/generators/lib/PBXProj/src/Identifiers.swift index 5a43620184..b871617302 100644 --- a/tools/generators/lib/PBXProj/src/Identifiers.swift +++ b/tools/generators/lib/PBXProj/src/Identifiers.swift @@ -258,6 +258,9 @@ FF01000000000000000001\#(byteHexStrings[index]!) \# /// A `XCVersionGroup` or child `PBXFileReference`. case coreData = "3" + + /// A `PBXFileSystemSynchronizedRootGroup`. + case synchronizedRootGroup = "4" } public static func mainGroup(_ path: String) -> String { @@ -304,6 +307,28 @@ FF0000000000000000000008 /* rules_xcodeproj */ return #"FE\#(hash) /* \#(name) */"# } + /// Calculates the identifier for a + /// `PBXFileSystemSynchronizedRootGroup` at `path`. + /// + /// Unlike `element()`, this doesn't use a hash cache: the targets + /// that a synchronized folder belongs to reference it by this + /// identifier, and they are created by another generator, so the + /// identifier can only depend on `path`. + /// + /// - Parameters: + /// - path: The path of the folder. + /// - name: The name of the folder, used in a comment. + public static func synchronizedRootGroup( + _ path: String, + name: String + ) -> String { + let hash = elementHash( + path + ElementType.synchronizedRootGroup.rawValue, + retryCount: 0 + ) + return #"FE\#(hash) /* \#(name) */"# + } + /// Calculates a unique hash for the path encoded in `hashable`. The /// hash needs to be unique among all of the values in `hashCache`, /// because two different `hashable` might hash to the same value, and diff --git a/tools/generators/pbxnativetargets/src/Generator/CalculatePlatformVariants.swift b/tools/generators/pbxnativetargets/src/Generator/CalculatePlatformVariants.swift index 7b3066bb05..232ba674d6 100644 --- a/tools/generators/pbxnativetargets/src/Generator/CalculatePlatformVariants.swift +++ b/tools/generators/pbxnativetargets/src/Generator/CalculatePlatformVariants.swift @@ -59,6 +59,7 @@ extension Generator.CalculatePlatformVariants { ) { var srcs: [[BazelPath]] = [] var nonArcSrcs: [[BazelPath]] = [] + var synchronizedFolders: [[BazelPath]] = [] var excludableFilesKeysWithValues: [(TargetID, Set)] = [] for id in ids { let targetArguments = try targetArguments.value( @@ -68,6 +69,7 @@ extension Generator.CalculatePlatformVariants { srcs.append(targetArguments.srcs) nonArcSrcs.append(targetArguments.nonArcSrcs) + synchronizedFolders.append(targetArguments.synchronizedFolders) excludableFilesKeysWithValues.append( ( @@ -141,7 +143,8 @@ extension Generator.CalculatePlatformVariants { let consolidatedInputs = Target.ConsolidatedInputs( srcs: consolidatePaths(srcs), - nonArcSrcs: consolidatePaths(nonArcSrcs) + nonArcSrcs: consolidatePaths(nonArcSrcs), + synchronizedFolders: consolidatePaths(synchronizedFolders) ) return (platformVariants, allConditionalFiles, consolidatedInputs) diff --git a/tools/generators/pbxnativetargets/src/Generator/CreateBuildPhases.swift b/tools/generators/pbxnativetargets/src/Generator/CreateBuildPhases.swift index 867067fb07..a9c0018fdc 100644 --- a/tools/generators/pbxnativetargets/src/Generator/CreateBuildPhases.swift +++ b/tools/generators/pbxnativetargets/src/Generator/CreateBuildPhases.swift @@ -198,7 +198,8 @@ extension Generator.CreateBuildPhases { let hasCompilePhase = productType.hasCompilePhase let hasCompileStub = hasCompilePhase && consolidatedInputs.srcs.isEmpty && - consolidatedInputs.nonArcSrcs.isEmpty + consolidatedInputs.nonArcSrcs.isEmpty && + consolidatedInputs.synchronizedFolders.isEmpty if hasLinkParams { buildPhases.append( diff --git a/tools/generators/pbxnativetargets/src/Generator/CreateTarget.swift b/tools/generators/pbxnativetargets/src/Generator/CreateTarget.swift index 586f3fe1d3..b8d4cc4bc5 100644 --- a/tools/generators/pbxnativetargets/src/Generator/CreateTarget.swift +++ b/tools/generators/pbxnativetargets/src/Generator/CreateTarget.swift @@ -178,7 +178,8 @@ extension Generator.CreateTarget { setsProductReference: setsProductReference, dependencySubIdentifiers: entry.dependencySubIdentifiers, buildConfigurationListIdentifier: configurationList.identifier, - buildPhaseIdentifiers: buildPhases.map(\.identifier) + buildPhaseIdentifiers: buildPhases.map(\.identifier), + synchronizedFolders: consolidatedInputs.synchronizedFolders ) let buildFileSubIdentifiers = diff --git a/tools/generators/pbxnativetargets/src/Generator/CreateTargetObject.swift b/tools/generators/pbxnativetargets/src/Generator/CreateTargetObject.swift index 12fd71700e..4a577fdbda 100644 --- a/tools/generators/pbxnativetargets/src/Generator/CreateTargetObject.swift +++ b/tools/generators/pbxnativetargets/src/Generator/CreateTargetObject.swift @@ -20,7 +20,8 @@ extension Generator { setsProductReference: Bool, dependencySubIdentifiers: [Identifiers.Targets.SubIdentifier], buildConfigurationListIdentifier: String, - buildPhaseIdentifiers: [String] + buildPhaseIdentifiers: [String], + synchronizedFolders: [BazelPath] = [] ) -> Object { return callable( /*identifier:*/ identifier, @@ -31,7 +32,8 @@ extension Generator { /*dependencySubIdentifiers:*/ dependencySubIdentifiers, /*buildConfigurationListIdentifier:*/ buildConfigurationListIdentifier, - /*buildPhaseIdentifiers:*/ buildPhaseIdentifiers + /*buildPhaseIdentifiers:*/ buildPhaseIdentifiers, + /*synchronizedFolders:*/ synchronizedFolders ) } } @@ -48,7 +50,8 @@ extension Generator.CreateTargetObject { _ setsProductReference: Bool, _ dependencySubIdentifiers: [Identifiers.Targets.SubIdentifier], _ buildConfigurationListIdentifier: String, - _ buildPhaseIdentifiers: [String] + _ buildPhaseIdentifiers: [String], + _ synchronizedFolders: [BazelPath] ) -> Object static func defaultCallable( @@ -59,7 +62,8 @@ extension Generator.CreateTargetObject { setsProductReference: Bool, dependencySubIdentifiers: [Identifiers.Targets.SubIdentifier], buildConfigurationListIdentifier: String, - buildPhaseIdentifiers: [String] + buildPhaseIdentifiers: [String], + synchronizedFolders: [BazelPath] ) -> Object { let productReference: String if setsProductReference { @@ -73,6 +77,34 @@ extension Generator.CreateTargetObject { productReference = "" } + // Folders whose contents Xcode adds to the target, created as + // `PBXFileSystemSynchronizedRootGroup`s by the files_and_groups + // generator with the same identifiers + let fileSystemSynchronizedGroups: String + if synchronizedFolders.isEmpty { + fileSystemSynchronizedGroups = "" + } else { + fileSystemSynchronizedGroups = #""" + fileSystemSynchronizedGroups = ( +\#( + synchronizedFolders + .map { folder in + let name = folder.path.split(separator: "/").last + .map(String.init) ?? folder.path + return """ +\t\t\t\t\( + Identifiers.FilesAndGroups.synchronizedRootGroup(folder.path, name: name) +), + +""" + } + .joined() +)\# + ); + +"""# + } + // The tabs for indenting are intentional let content = #""" { @@ -100,6 +132,7 @@ extension Generator.CreateTargetObject { .joined() )\# ); +\#(fileSystemSynchronizedGroups)\# name = \#(identifier.pbxProjEscapedName); productName = \#(productName.pbxProjEscaped); \#(productReference)\# diff --git a/tools/generators/pbxnativetargets/src/Generator/Target.swift b/tools/generators/pbxnativetargets/src/Generator/Target.swift index 2f2fdd30db..e4c370d7b4 100644 --- a/tools/generators/pbxnativetargets/src/Generator/Target.swift +++ b/tools/generators/pbxnativetargets/src/Generator/Target.swift @@ -7,6 +7,7 @@ enum Target { struct ConsolidatedInputs: Equatable { var srcs: [BazelPath] var nonArcSrcs: [BazelPath] + var synchronizedFolders: [BazelPath] = [] } struct Host: Equatable { diff --git a/tools/generators/pbxnativetargets/src/Generator/TargetArguments.swift b/tools/generators/pbxnativetargets/src/Generator/TargetArguments.swift index 31149ea0e8..6503bb1f3e 100644 --- a/tools/generators/pbxnativetargets/src/Generator/TargetArguments.swift +++ b/tools/generators/pbxnativetargets/src/Generator/TargetArguments.swift @@ -27,6 +27,7 @@ struct TargetArguments: Equatable { // FIXME: Extract to `Inputs` type let srcs: [BazelPath] let nonArcSrcs: [BazelPath] + var synchronizedFolders: [BazelPath] = [] let dSYMPathsBuildSetting: String } @@ -88,6 +89,11 @@ extension Dictionary { as: BazelPath.self, in: url ) + let synchronizedFolders = try rawArgs.consumeArgs( + "synchronized-folders", + as: BazelPath.self, + in: url + ) let xcodeConfigurations = try rawArgs.consumeArgs( "xcode-configurations", in: url @@ -130,6 +136,7 @@ extension Dictionary { hasCxxParams: hasCxxParams, srcs: srcs, nonArcSrcs: nonArcSrcs, + synchronizedFolders: synchronizedFolders, dSYMPathsBuildSetting: dSYMPathsBuildSetting ) ) diff --git a/tools/generators/pbxnativetargets/test/Generator/CreateTargetObjectTests.swift b/tools/generators/pbxnativetargets/test/Generator/CreateTargetObjectTests.swift index d22229a1b9..d844f1f08a 100644 --- a/tools/generators/pbxnativetargets/test/Generator/CreateTargetObjectTests.swift +++ b/tools/generators/pbxnativetargets/test/Generator/CreateTargetObjectTests.swift @@ -65,7 +65,8 @@ class CreateTargetObjectTests: XCTestCase { setsProductReference: setsProductReference, dependencySubIdentifiers: dependencySubIdentifiers, buildConfigurationListIdentifier: buildConfigurationListIdentifier, - buildPhaseIdentifiers: buildPhaseIdentifiers + buildPhaseIdentifiers: buildPhaseIdentifiers, + synchronizedFolders: [] ) // Assert @@ -140,7 +141,8 @@ class CreateTargetObjectTests: XCTestCase { setsProductReference: setsProductReference, dependencySubIdentifiers: dependencySubIdentifiers, buildConfigurationListIdentifier: buildConfigurationListIdentifier, - buildPhaseIdentifiers: buildPhaseIdentifiers + buildPhaseIdentifiers: buildPhaseIdentifiers, + synchronizedFolders: [] ) // Assert @@ -207,11 +209,86 @@ class CreateTargetObjectTests: XCTestCase { setsProductReference: setsProductReference, dependencySubIdentifiers: dependencySubIdentifiers, buildConfigurationListIdentifier: buildConfigurationListIdentifier, - buildPhaseIdentifiers: buildPhaseIdentifiers + buildPhaseIdentifiers: buildPhaseIdentifiers, + synchronizedFolders: [] ) // Assert XCTAssertNoDifference(object, expectedObject) } + + func test_synchronizedFolders() { + // Arrange + + let identifier = Identifiers.Targets.Identifier( + pbxProjEscapedName: "a".pbxProjEscaped, + subIdentifier: .init(shard: "A_SHARD", hash: "A_HASH"), + full: "A_ID /* a */", + withoutComment: "A_ID" + ) + let productSubIdentifier = Identifiers.BuildFiles.SubIdentifier( + shard: "B_SHARD", + type: .product, + path: "product.basename", + hash: "B_HASH" + ) + let synchronizedFolders: [BazelPath] = [ + "a/Sources", + "a/Tests", + ] + let sourcesIdentifier = Identifiers.FilesAndGroups + .synchronizedRootGroup("a/Sources", name: "Sources") + let testsIdentifier = Identifiers.FilesAndGroups + .synchronizedRootGroup("a/Tests", name: "Tests") + + // The tabs for indenting are intentional + let expectedObject = Object( + identifier: "A_ID /* a */", + content: #""" +{ + isa = PBXNativeTarget; + buildConfigurationList = BCL_ID; + buildPhases = ( + BPA, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + \#(sourcesIdentifier), + \#(testsIdentifier), + ); + name = a; + productName = A; + productType = "com.apple.product-type.library.static"; + } +"""# + ) + + // Act + + let object = Generator.CreateTargetObject.defaultCallable( + identifier: identifier, + productType: .staticLibrary, + productName: "A", + productSubIdentifier: productSubIdentifier, + setsProductReference: false, + dependencySubIdentifiers: [], + buildConfigurationListIdentifier: "BCL_ID", + buildPhaseIdentifiers: ["BPA"], + synchronizedFolders: synchronizedFolders + ) + + // Assert + + XCTAssertNoDifference(object, expectedObject) + XCTAssertEqual( + sourcesIdentifier, + Identifiers.FilesAndGroups + .synchronizedRootGroup("a/Sources", name: "Sources"), + "The identifier must only depend on the path" + ) + } } diff --git a/tools/generators/pbxproj_prefix/src/Generator/Arguments.swift b/tools/generators/pbxproj_prefix/src/Generator/Arguments.swift index c9988bbe15..ed9833fb95 100644 --- a/tools/generators/pbxproj_prefix/src/Generator/Arguments.swift +++ b/tools/generators/pbxproj_prefix/src/Generator/Arguments.swift @@ -74,6 +74,11 @@ Minimum Xcode version that the generated project supports. @Argument(help: "Development region for the project.") var developmentRegion: String + @Flag(help: """ +Whether the project has `PBXFileSystemSynchronizedRootGroup`s. +""") + var usesSynchronizedFolders = false + @Option(help: """ Populates the `ORGANIZATIONNAME` attribute for the project. """) diff --git a/tools/generators/pbxproj_prefix/src/Generator/Environment.swift b/tools/generators/pbxproj_prefix/src/Generator/Environment.swift index 5dfaf03546..cd33759110 100644 --- a/tools/generators/pbxproj_prefix/src/Generator/Environment.swift +++ b/tools/generators/pbxproj_prefix/src/Generator/Environment.swift @@ -57,7 +57,8 @@ extension Generator { let pbxProjPrefixPartial: ( _ bazelDependenciesPartial: String, _ pbxProjectPrefixPartial: String, - _ xcodeVersion: SemanticVersion + _ xcodeVersion: SemanticVersion, + _ usesSynchronizedFolders: Bool ) -> String let projectDir: (_ executionRoot: String) -> String diff --git a/tools/generators/pbxproj_prefix/src/Generator/Generator.swift b/tools/generators/pbxproj_prefix/src/Generator/Generator.swift index 3f317c54f7..bc51ba317e 100644 --- a/tools/generators/pbxproj_prefix/src/Generator/Generator.swift +++ b/tools/generators/pbxproj_prefix/src/Generator/Generator.swift @@ -83,7 +83,9 @@ struct Generator { environment.pbxProjPrefixPartial( /*bazelDependenciesPartial:*/ bazelDependenciesPartial, /*pbxProjectPrefixPartial:*/ pbxProjectPrefixPartial, - /*minimumXcodeVersion:*/ arguments.minimumXcodeVersion + /*minimumXcodeVersion:*/ arguments.minimumXcodeVersion, + /*usesSynchronizedFolders:*/ + arguments.usesSynchronizedFolders ), to: arguments.outputPath ) diff --git a/tools/generators/pbxproj_prefix/src/Generator/PBXProjPrefixPartial.swift b/tools/generators/pbxproj_prefix/src/Generator/PBXProjPrefixPartial.swift index e66b812e5c..2507918e39 100644 --- a/tools/generators/pbxproj_prefix/src/Generator/PBXProjPrefixPartial.swift +++ b/tools/generators/pbxproj_prefix/src/Generator/PBXProjPrefixPartial.swift @@ -6,8 +6,17 @@ extension Generator { static func pbxProjPrefixPartial( bazelDependenciesPartial: String, pbxProjectPrefixPartial: String, - minimumXcodeVersion: SemanticVersion + minimumXcodeVersion: SemanticVersion, + usesSynchronizedFolders: Bool = false ) -> String { + // `PBXFileSystemSynchronizedRootGroup`s need object version 70, which + // is what Xcode writes for such projects with the compatibility + // version that we set. Other projects keep the version for + // `minimumXcodeVersion`, so they don't change. + let objectVersion = usesSynchronizedFolders ? + max(minimumXcodeVersion.pbxProjObjectVersion, 70) : + minimumXcodeVersion.pbxProjObjectVersion + // This is a `PBXProj` partial for the start of the `PBXProj` element. // // The tabs for indenting are intentional. The trailing newlines are @@ -19,7 +28,7 @@ extension Generator { archiveVersion = 1; classes = { }; - objectVersion = \#(minimumXcodeVersion.pbxProjObjectVersion); + objectVersion = \#(objectVersion); objects = { \#(bazelDependenciesPartial)\# \#(pbxProjectPrefixPartial)\# diff --git a/tools/generators/pbxproj_prefix/test/PBXProjPrefixPartialTests.swift b/tools/generators/pbxproj_prefix/test/PBXProjPrefixPartialTests.swift index 83088157c4..0d2a877fc9 100644 --- a/tools/generators/pbxproj_prefix/test/PBXProjPrefixPartialTests.swift +++ b/tools/generators/pbxproj_prefix/test/PBXProjPrefixPartialTests.swift @@ -150,4 +150,41 @@ class PBXProjPrefixPartialTests: XCTestCase { expectedPbxProjPrefixPartial ) } + + func test_synchronizedFolders() { + // Arrange + + let bazelDependenciesPartial = "{BazelDependencies_Partial}\n" + let pbxProjectPrefixPartial = "{PBXProject_Prefix_Partial}\n" + let minimumXcodeVersion: SemanticVersion = "15.0" + + let expectedPbxProjPrefixPartial = #""" +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 70; + objects = { +{BazelDependencies_Partial} +{PBXProject_Prefix_Partial} + +"""# + + // Act + + let pbxProjPrefixPartial = Generator.pbxProjPrefixPartial( + bazelDependenciesPartial: bazelDependenciesPartial, + pbxProjectPrefixPartial: pbxProjectPrefixPartial, + minimumXcodeVersion: minimumXcodeVersion, + usesSynchronizedFolders: true + ) + + // Assert + + XCTAssertNoDifference( + pbxProjPrefixPartial, + expectedPbxProjPrefixPartial + ) + } } diff --git a/xcodeproj/internal/docs/aspect_hints.bzl b/xcodeproj/internal/docs/aspect_hints.bzl index 8ab7c9bdc4..e502b00a9d 100644 --- a/xcodeproj/internal/docs/aspect_hints.bzl +++ b/xcodeproj/internal/docs/aspect_hints.bzl @@ -8,5 +8,10 @@ load( "//xcodeproj:xcodeproj_extra_files.bzl", _xcodeproj_extra_files = "xcodeproj_extra_files", ) +load( + "//xcodeproj:xcodeproj_synchronized_folders.bzl", + _xcodeproj_synchronized_folders = "xcodeproj_synchronized_folders", +) xcodeproj_extra_files = _xcodeproj_extra_files +xcodeproj_synchronized_folders = _xcodeproj_synchronized_folders diff --git a/xcodeproj/internal/files/input_files.bzl b/xcodeproj/internal/files/input_files.bzl index 226680d75f..e661d832c4 100644 --- a/xcodeproj/internal/files/input_files.bzl +++ b/xcodeproj/internal/files/input_files.bzl @@ -1,5 +1,6 @@ """Module containing functions dealing with target input files.""" +load("@bazel_skylib//lib:paths.bzl", "paths") load( "@build_bazel_rules_apple//apple:providers.bzl", "AppleResourceInfo", @@ -11,7 +12,11 @@ load( "EMPTY_LIST", "memory_efficient_depset", ) -load("//xcodeproj/internal:providers.bzl", "XcodeProjExtraFilesHintInfo") +load( + "//xcodeproj/internal:providers.bzl", + "XcodeProjExtraFilesHintInfo", + "XcodeProjSynchronizedFoldersHintInfo", +) load(":linker_input_files.bzl", "linker_input_files") load(":resources.bzl", resources_module = "resources") @@ -307,6 +312,9 @@ def _collect_input_files( `target`'s `non_arc_srcs`-like attributes. * `srcs`: A `list` of `File`s that are inputs to `target`'s `srcs`-like attributes. + * `synchronized_folders`: A `depset` of path strings of + folders from the `xcodeproj_synchronized_folders` aspect + hint, which are shown as synchronized folders. * A `struct`, which will end up in `XcodeProjInfo.inputs`, with the following fields: @@ -409,6 +417,33 @@ def _collect_input_files( rule_files = ctx.rule.files, ) + # Folders from the `xcodeproj_synchronized_folders` aspect hint are shown + # as synchronized folders, so the sources inside them aren't listed + # individually + synchronized_folders = [] + if automatic_target_info.srcs: + package_dir = paths.join(label.workspace_root, label.package) + for hint in rule_attr.aspect_hints: + if XcodeProjSynchronizedFoldersHintInfo in hint: + synchronized_folders.extend([ + paths.join(package_dir, folder) + for folder in ( + hint[XcodeProjSynchronizedFoldersHintInfo].folders + ) + ]) + if synchronized_folders: + folder_prefixes = tuple([folder + "/" for folder in synchronized_folders]) + srcs = [ + file + for file in srcs + if not file.path.startswith(folder_prefixes) + ] + non_arc_srcs = [ + file + for file in non_arc_srcs + if not file.path.startswith(folder_prefixes) + ] + # Collect extra fila provided via the `xcodeproj_extra_files` aspect hint for hint in rule_attr.aspect_hints: if XcodeProjExtraFilesHintInfo in hint: @@ -560,6 +595,9 @@ def _collect_input_files( infoplist = infoplist, non_arc_srcs = memory_efficient_depset(non_arc_srcs), srcs = memory_efficient_depset(srcs), + synchronized_folders = memory_efficient_depset( + synchronized_folders, + ), ), ), struct( @@ -665,6 +703,7 @@ def _collect_mixed_language_input_files( infoplist = None, non_arc_srcs = EMPTY_LIST, srcs = EMPTY_LIST, + synchronized_folders = EMPTY_DEPSET, ), struct( _product_framework_files = EMPTY_DEPSET, @@ -688,6 +727,7 @@ def _collect_mixed_language_input_files( infoplist = None, non_arc_srcs = mergeable_info.non_arc_srcs, srcs = mergeable_info.srcs, + synchronized_folders = mergeable_info.synchronized_folders, ), struct( # Framework files only come from top-level targets, so no need to diff --git a/xcodeproj/internal/pbxproj_partials.bzl b/xcodeproj/internal/pbxproj_partials.bzl index fb925db83d..a0071bfed7 100644 --- a/xcodeproj/internal/pbxproj_partials.bzl +++ b/xcodeproj/internal/pbxproj_partials.bzl @@ -123,6 +123,8 @@ _FLAGS = struct( platforms = "--platforms", post_build_script = "--post-build-script", pre_build_script = "--pre-build-script", + synchronized_folders_file = "--synchronized-folders-file", + uses_synchronized_folders = "--uses-synchronized-folders", target_and_test_hosts = "--target-and-test-hosts", target_and_watch_kit_extensions = "--target-and-watch-kit-extensions", use_base_internationalization = "--use-base-internationalization", @@ -310,6 +312,13 @@ def _write_consolidation_map_targets( terminate_with = "", ) + # synchronizedFolders + targets_args.add_all( + xcode_target.inputs.synchronized_folders, + omit_if_empty = False, + terminate_with = "", + ) + targets_args.add_all( xcode_target_configurations[xcode_target.id], omit_if_empty = False, @@ -408,6 +417,7 @@ def _write_files_and_groups( install_path, project_options, selected_model_versions_file, + synchronized_folders = EMPTY_DEPSET, tool, workspace_directory): """Creates `File`s representing files and groups in a `.pbxproj`. @@ -434,6 +444,8 @@ def _write_files_and_groups( selected_model_versions_file: A `File` that contains a JSON representation of `[BazelPath: String]`, mapping `.xcdatamodeld` file paths to selected `.xcdatamodel` file names. + synchronized_folders: A `depset` of paths of folders to create as + `PBXFileSystemSynchronizedRootGroup`s. tool: The executable that will generate the output files. workspace_directory: The absolute path to the Bazel workspace directory. @@ -484,6 +496,10 @@ def _write_files_and_groups( # TODO: Consider moving normalization into `args.add_all.map_each` file_paths_args.add_all(file_paths) + # Synchronized folders are placed in the tree like files, and created as + # `PBXFileSystemSynchronizedRootGroup`s. + file_paths_args.add_all(synchronized_folders) + actions.write(file_paths_file, file_paths_args) # generatedFilePaths @@ -505,6 +521,20 @@ def _write_files_and_groups( actions.write(generated_file_paths_file, generated_file_paths_args) + # synchronizedFoldersFile + + synchronized_folders_file = actions.declare_file( + "{}_pbxproj_partials/synchronized_folders_file".format( + generator_name, + ), + ) + + synchronized_folders_args = actions.args() + synchronized_folders_args.set_param_file_format("multiline") + synchronized_folders_args.add_all(synchronized_folders) + + actions.write(synchronized_folders_file, synchronized_folders_args) + # ... the rest # knownRegionsOutputPath @@ -546,6 +576,9 @@ def _write_files_and_groups( # developmentRegion args.add(project_options["development_region"]) + # synchronizedFoldersFile + args.add(_FLAGS.synchronized_folders_file, synchronized_folders_file) + # useBaseInternationalization args.add(_FLAGS.use_base_internationalization) @@ -573,6 +606,7 @@ def _write_files_and_groups( generated_file_paths_file, execution_root_file, selected_model_versions_file, + synchronized_folders_file, ] + buildfile_subidentifiers_files, outputs = [ files_and_groups, @@ -675,6 +709,7 @@ def _write_pbxproj_prefix( suppress_coverage_build, target_ids_list, tool, + uses_synchronized_folders = False, workspace_directory, xcode_configurations): """Creates a `File` containing a `PBXProject` prefix `PBXProj` partial. @@ -710,6 +745,8 @@ def _write_pbxproj_prefix( when `CLANG_COVERAGE_MAPPING` is set. target_ids_list: A `File` containing a list of target IDs. tool: The executable that will generate the `PBXProj` partial. + uses_synchronized_folders: Whether the project has + `PBXFileSystemSynchronizedRootGroup`s. workspace_directory: The absolute path to the Bazel workspace directory. xcode_configurations: A sorted sequence of Xcode configuration names. @@ -775,6 +812,9 @@ def _write_pbxproj_prefix( if organization_name: args.add(_FLAGS.organization_name, organization_name) + if uses_synchronized_folders: + args.add(_FLAGS.uses_synchronized_folders) + # platforms args.add_all( _FLAGS.platforms, diff --git a/xcodeproj/internal/processed_targets/mergeable_infos.bzl b/xcodeproj/internal/processed_targets/mergeable_infos.bzl index 3ba339057b..6de52cc3c0 100644 --- a/xcodeproj/internal/processed_targets/mergeable_infos.bzl +++ b/xcodeproj/internal/processed_targets/mergeable_infos.bzl @@ -32,7 +32,8 @@ def _calculate_mergeable_info( product_type, xcode_inputs): # We can only merge if this target doesn't have its own sources - if xcode_inputs.srcs or xcode_inputs.non_arc_srcs: + if (xcode_inputs.srcs or xcode_inputs.non_arc_srcs or + xcode_inputs.synchronized_folders): return None avoid_ids = { @@ -146,6 +147,7 @@ def _cc_mergeable_info(*, mergeable_info): previews_include_path = EMPTY_STRING, product_files = (mergeable_info.product_file,), srcs = mergeable_info.inputs.srcs, + synchronized_folders = mergeable_info.inputs.synchronized_folders, swift_args = EMPTY_LIST, swift_debug_settings_to_merge = mergeable_info.swift_debug_settings, ), @@ -246,6 +248,12 @@ def _mixed_language_mergeable_info( cc.inputs.srcs, ], ), + synchronized_folders = memory_efficient_depset( + transitive = [ + swift.inputs.synchronized_folders, + cc.inputs.synchronized_folders, + ], + ), swift_args = swift.args.swift, swift_debug_settings_to_merge = swift.swift_debug_settings, ), @@ -276,6 +284,7 @@ def _swift_mergeable_info(*, dynamic_frameworks, mergeable_info, product_type): previews_include_path = previews_info.include_path, product_files = (mergeable_info.product_file,), srcs = mergeable_info.inputs.srcs, + synchronized_folders = mergeable_info.inputs.synchronized_folders, swift_args = mergeable_info.args.swift, swift_debug_settings_to_merge = mergeable_info.swift_debug_settings, ), diff --git a/xcodeproj/internal/providers.bzl b/xcodeproj/internal/providers.bzl index 57df8ef08c..07ba41bf7f 100644 --- a/xcodeproj/internal/providers.bzl +++ b/xcodeproj/internal/providers.bzl @@ -16,6 +16,19 @@ XcodeProjRunnerOutputInfo = provider( }, ) +XcodeProjSynchronizedFoldersHintInfo = provider( + doc = """\ +Provides the folders of a target to show as synchronized folders during project +generation. +""", + fields = { + "folders": """\ +A `tuple` of folder paths, relative to the package of the target that the hint +is attached to. +""", + }, +) + XcodeProjExtraFilesHintInfo = provider( doc = "Provides a list of extra files to include during project generation", fields = { diff --git a/xcodeproj/internal/xcode_targets.bzl b/xcodeproj/internal/xcode_targets.bzl index 886937238c..b550117cde 100644 --- a/xcodeproj/internal/xcode_targets.bzl +++ b/xcodeproj/internal/xcode_targets.bzl @@ -30,6 +30,7 @@ def _from_resource_bundle(bundle): infoplist = None, non_arc_srcs = EMPTY_DEPSET, srcs = EMPTY_DEPSET, + synchronized_folders = EMPTY_DEPSET, ), label = None, ) @@ -125,7 +126,11 @@ def _make_xcode_target( elif mergeable_info: compile_stub_needed = ( product.type not in _NON_COMPILE_PRODUCT_TYPES and - not (mergeable_info.srcs or mergeable_info.non_arc_srcs) + not ( + mergeable_info.srcs or + mergeable_info.non_arc_srcs or + mergeable_info.synchronized_folders + ) ) inputs = _merge_xcode_inputs( dest_inputs = inputs, @@ -134,7 +139,11 @@ def _make_xcode_target( else: compile_stub_needed = ( product.type not in _NON_COMPILE_PRODUCT_TYPES and - not (inputs.srcs or inputs.non_arc_srcs) + not ( + inputs.srcs or + inputs.non_arc_srcs or + inputs.synchronized_folders + ) ) if mergeable_info: @@ -184,6 +193,7 @@ def _merge_xcode_inputs(*, dest_inputs, mergeable_info): infoplist = dest_inputs.infoplist, non_arc_srcs = mergeable_info.non_arc_srcs, srcs = mergeable_info.srcs, + synchronized_folders = mergeable_info.synchronized_folders, ) def _to_xcode_target_outputs(outputs): diff --git a/xcodeproj/internal/xcodeproj_rule.bzl b/xcodeproj/internal/xcodeproj_rule.bzl index 9b389363bc..00669aa30e 100644 --- a/xcodeproj/internal/xcodeproj_rule.bzl +++ b/xcodeproj/internal/xcodeproj_rule.bzl @@ -131,6 +131,7 @@ def _collect_files( transitive_files = [unsupported_extra_files] transitive_generated_file_paths = [] transitive_srcs = [] + transitive_synchronized_folders = [] for xcode_target in all_targets: transitive_file_paths.append(xcode_target.inputs.extra_file_paths) transitive_files.append(xcode_target.inputs.extra_files) @@ -139,6 +140,9 @@ def _collect_files( ) transitive_srcs.append(xcode_target.inputs.non_arc_srcs) transitive_srcs.append(xcode_target.inputs.srcs) + transitive_synchronized_folders.append( + xcode_target.inputs.synchronized_folders, + ) label = xcode_target.label if label: @@ -162,6 +166,7 @@ def _collect_files( transitive = transitive_files, ) generated_file_paths = depset(transitive = transitive_generated_file_paths) + synchronized_folders = depset(transitive = transitive_synchronized_folders) return ( compile_stub_needed, @@ -170,6 +175,7 @@ def _collect_files( generated_file_paths, infoplists, srcs, + synchronized_folders, ) def _get_minimum_xcode_version(*, xcode_config): @@ -361,6 +367,7 @@ def _write_project_contents( generated_file_paths, infoplists, srcs, + synchronized_folders, ) = _collect_files( owned_extra_files = owned_extra_files, resource_bundle_xcode_targets = resource_bundle_xcode_targets, @@ -422,6 +429,7 @@ def _write_project_contents( install_path = install_path, project_options = project_options, selected_model_versions_file = selected_model_versions_file, + synchronized_folders = synchronized_folders, tool = files_and_groups_generator, workspace_directory = workspace_directory, ) @@ -447,6 +455,7 @@ def _write_project_contents( suppress_coverage_build = suppress_coverage_build, target_ids_list = target_ids_list, tool = pbxproj_prefix_generator, + uses_synchronized_folders = bool(synchronized_folders), xcode_configurations = xcode_configurations, workspace_directory = workspace_directory, ) diff --git a/xcodeproj/xcodeproj_synchronized_folders.bzl b/xcodeproj/xcodeproj_synchronized_folders.bzl new file mode 100644 index 0000000000..89815ae7c5 --- /dev/null +++ b/xcodeproj/xcodeproj_synchronized_folders.bzl @@ -0,0 +1,73 @@ +"""Rule for showing a target's source folders as synchronized folders""" + +load("//xcodeproj/internal:providers.bzl", "XcodeProjSynchronizedFoldersHintInfo") + +def _xcodeproj_synchronized_folders_impl(ctx): + """Create a provider to surface synchronized folders via an aspect hint. + + Args: + ctx: The rule context. + + Returns: + A `XcodeProjSynchronizedFoldersHintInfo` provider. + """ + for folder in ctx.attr.folders: + if not folder or folder.startswith("/") or ".." in folder.split("/"): + fail("{}: folder '{}' must be a path relative to a package".format( + ctx.label, + folder, + )) + return [XcodeProjSynchronizedFoldersHintInfo( + folders = tuple(ctx.attr.folders), + )] + +xcodeproj_synchronized_folders = rule( + doc = """\ +This rule is used to show a target's source folders in Xcode as synchronized +folders (Xcode 16's buildable folders), whose contents Xcode keeps up to date on +its own: files added to such a folder appear in the target without regenerating +the project. The provider created by this rule should be attached to the +related target via an aspect hint. + +The folders are relative to the package of the target that the hint is attached +to, so one hint can be shared by targets in different packages that use the +same layout. The target's sources inside the folders are shown by the folders +instead of individually. + +Bazel still only compiles the target's `srcs`, so they should be every source +file in the folders, typically with a `glob`; otherwise a file added in Xcode +appears in the target but isn't built. + +**EXAMPLE** + +```starlark +load( + "@rules_xcodeproj//xcodeproj:xcodeproj_synchronized_folders.bzl", + "xcodeproj_synchronized_folders", +) + +swift_library( + ... + srcs = glob(["Sources/**/*.swift"]), + aspect_hints = [":sources_folder"], + ... +) + +# Show `Sources` as a synchronized folder of the Swift library in Xcode +xcodeproj_synchronized_folders( + name = "sources_folder", + folders = ["Sources"], +) +``` +""", + implementation = _xcodeproj_synchronized_folders_impl, + attrs = { + "folders": attr.string_list( + doc = """\ +The folders to show as synchronized folders, relative to the package of the +target that the hint is attached to. +""", + mandatory = True, + ), + }, +)