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,
+ ),
+ },
+)