diff --git a/swift/internal/actions.bzl b/swift/internal/actions.bzl index e5e0d60b6..8a1e25ee0 100644 --- a/swift/internal/actions.bzl +++ b/swift/internal/actions.bzl @@ -43,6 +43,7 @@ def _apply_action_configs( additional_tools = [] inputs = [] transitive_inputs = [] + unused_inputs = [] for action_config in swift_toolchain.action_configs: # Skip the action config if it does not apply to the requested action. @@ -101,12 +102,14 @@ def _apply_action_configs( additional_tools.extend(action_inputs.additional_tools) inputs.extend(action_inputs.inputs) transitive_inputs.extend(action_inputs.transitive_inputs) + unused_inputs.extend(action_inputs.unused_inputs) # Merge the action results into a single result that we return. return ConfigResultInfo( additional_tools = additional_tools, inputs = inputs, transitive_inputs = transitive_inputs, + unused_inputs = unused_inputs, ) def is_action_enabled(action_name, swift_toolchain = None, toolchains = None): @@ -227,23 +230,90 @@ def run_toolchain_action( swift_toolchain = swift_toolchain, ) - actions.run( - arguments = [tool_executable_args, args], - env = tool_config.env, - exec_group = exec_group, - executable = executable, - toolchain = toolchain_type, - execution_requirements = execution_requirements, - inputs = depset( - action_inputs.inputs, + # Handle unused inputs for cache optimization. When unused_inputs is provided, + # we use Bazel's unused_inputs_list mechanism to exclude certain inputs from + # the action cache key calculation. + # + # According to Bazel docs, if unused_inputs_list file is in inputs, the inputs + # are trimmed BEFORE the action executes (not part of cache key). + # If it's in outputs, inputs are trimmed AFTER the action executes. + # + # We need the file to exist before the action runs, so we use actions.write() + # to create it first, then include it in the action's inputs. + unused_inputs = action_inputs.unused_inputs + if unused_inputs: + # Create a file listing all unused inputs (one path per line) + # Use module_name from prerequisites to ensure uniqueness across targets + module_name = getattr(prerequisites, "module_name", None) + if module_name: + unused_inputs_filename = "{}_{}_unused_inputs.txt".format( + module_name, + action_name, + ) + else: + # Fallback: use output file path if available, otherwise action name + outputs = kwargs.get("outputs", []) + if outputs: + # Use the full path relative to output to ensure uniqueness + unused_inputs_filename = "{}_unused_inputs.txt".format( + outputs[0].short_path.replace("/", "_"), + ) + else: + unused_inputs_filename = "{}_unused_inputs.txt".format(action_name) + + unused_inputs_list_file = actions.declare_file(unused_inputs_filename) + + # Write the unused inputs list file. This creates a separate FileWrite + # action that must complete before the compile action can run. + actions.write( + output = unused_inputs_list_file, + content = "\n".join([f.path for f in unused_inputs]), + ) + + # Include unused inputs in the inputs depset, but pass the list file + # to actions.run so Bazel knows not to use them for caching. + # The unused_inputs_list_file must be in inputs for pre-execution trimming. + all_inputs = depset( + action_inputs.inputs + unused_inputs + [unused_inputs_list_file], transitive = action_inputs.transitive_inputs, - ), - mnemonic = mnemonic if mnemonic else action_name, - resource_set = tool_config.resource_set, - tools = depset( - tools, - transitive = action_inputs.additional_tools, - ), - use_default_shell_env = True, - **kwargs - ) + ) + + actions.run( + arguments = [tool_executable_args, args], + env = tool_config.env, + exec_group = exec_group, + executable = executable, + toolchain = toolchain_type, + execution_requirements = execution_requirements, + inputs = all_inputs, + mnemonic = mnemonic if mnemonic else action_name, + resource_set = tool_config.resource_set, + tools = depset( + tools, + transitive = action_inputs.additional_tools, + ), + unused_inputs_list = unused_inputs_list_file, + use_default_shell_env = True, + **kwargs + ) + else: + actions.run( + arguments = [tool_executable_args, args], + env = tool_config.env, + exec_group = exec_group, + executable = executable, + toolchain = toolchain_type, + execution_requirements = execution_requirements, + inputs = depset( + action_inputs.inputs, + transitive = action_inputs.transitive_inputs, + ), + mnemonic = mnemonic if mnemonic else action_name, + resource_set = tool_config.resource_set, + tools = depset( + tools, + transitive = action_inputs.additional_tools, + ), + use_default_shell_env = True, + **kwargs + ) diff --git a/swift/internal/compiling.bzl b/swift/internal/compiling.bzl index f676c59e1..628f0dfba 100644 --- a/swift/internal/compiling.bzl +++ b/swift/internal/compiling.bzl @@ -63,6 +63,7 @@ load( "SWIFT_FEATURE_THIN_LTO", "SWIFT_FEATURE_USE_C_MODULES", "SWIFT_FEATURE_USE_EXPLICIT_SWIFT_MODULE_MAP", + "SWIFT_FEATURE_USE_SWIFTINTERFACE_FOR_CACHING", "SWIFT_FEATURE__NUM_THREADS_0_IN_SWIFTCOPTS", "SWIFT_FEATURE__WMO_IN_SWIFTCOPTS", ) @@ -123,7 +124,17 @@ def _explicit_swift_module_map_info( feature_configuration, target_name, transitive_modules): - """Returns the explicit Swift module map file and matching Swift inputs.""" + """Returns the explicit Swift module map file and matching Swift inputs. + + Also returns a `unused_inputs` list. When + `swift.use_swiftinterface_for_caching` is enabled and every included module + has a swiftinterface, the returned `inputs` only carry the swiftinterface + files (participating in the action cache key) and the swiftmodule files are + moved into `unused_inputs` — sandboxed in but stripped from the cache key + via Bazel's `unused_inputs_list` mechanism. Modules lacking swiftinterface + (e.g., without `library_evolution`) fall back to swiftmodule going into + `inputs`, keeping correctness. + """ if is_feature_enabled( feature_configuration = feature_configuration, feature_name = SWIFT_FEATURE_USE_EXPLICIT_SWIFT_MODULE_MAP, @@ -142,11 +153,11 @@ def _explicit_swift_module_map_info( if module.is_system ] if not module_contexts: - return struct(file = None, inputs = []) + return struct(file = None, inputs = [], unused_inputs = []) filename = "{}.swift-system-explicit-module-map.json".format(target_name) else: - return struct(file = None, inputs = []) + return struct(file = None, inputs = [], unused_inputs = []) explicit_swift_module_map_file = actions.declare_file(filename) write_explicit_swift_module_map_file( @@ -154,9 +165,37 @@ def _explicit_swift_module_map_info( explicit_swift_module_map_file = explicit_swift_module_map_file, module_contexts = module_contexts, ) + use_swiftinterface_for_caching = is_feature_enabled( + feature_configuration = feature_configuration, + feature_name = SWIFT_FEATURE_USE_SWIFTINTERFACE_FOR_CACHING, + ) + if use_swiftinterface_for_caching: + inputs = [] + unused_inputs = [] + for module in module_contexts: + swift_module = module.swift + if not swift_module: + continue + interface_file = ( + swift_module.private_swiftinterface or + swift_module.swiftinterface + ) + if interface_file: + inputs.append(interface_file) + if type(swift_module.swiftmodule) == "File": + unused_inputs.append(swift_module.swiftmodule) + elif type(swift_module.swiftmodule) == "File": + # Fallback: no interface, keep swiftmodule in cache-key inputs. + inputs.append(swift_module.swiftmodule) + return struct( + file = explicit_swift_module_map_file, + inputs = inputs, + unused_inputs = unused_inputs, + ) return struct( file = explicit_swift_module_map_file, inputs = transitive_swift_dependency_inputs(module_contexts), + unused_inputs = [], ) def create_compilation_context(defines, srcs, transitive_modules): @@ -311,6 +350,28 @@ def compile_module_interface( # than the same `depset` being flattened and re-merged multiple times up # the build graph. transitive_modules = merged_swift_info.transitive_modules.to_list() + + # Collect each transitive Swift dep's swiftinterface and swiftmodule as + # two parallel lists, so that when `swift.use_swiftinterface_for_caching` + # is enabled, configurators can route the swiftinterfaces into `inputs` + # (participating in the action cache key) and the swiftmodules into + # `unused_inputs` (still sandboxed for the compiler, but excluded from the + # cache key). `private_swiftinterface` is preferred over `swiftinterface`, + # matching the selection in `transitive_swift_dependency_inputs`. + transitive_swiftinterfaces = [] + transitive_swiftmodules_only = [] + for module in transitive_modules: + swift_module = module.swift + if not swift_module: + continue + interface_file = ( + swift_module.private_swiftinterface or + swift_module.swiftinterface + ) + if interface_file: + transitive_swiftinterfaces.append(interface_file) + if type(swift_module.swiftmodule) == "File": + transitive_swiftmodules_only.append(swift_module.swiftmodule) transitive_swift_dependency_inputs_list = transitive_swift_dependency_inputs( transitive_modules, ) @@ -339,12 +400,19 @@ def compile_module_interface( else: indexstore_directory = None + # Determine if we should use swiftinterface files for caching + use_swiftinterface_for_caching = is_feature_enabled( + feature_configuration = feature_configuration, + feature_name = SWIFT_FEATURE_USE_SWIFTINTERFACE_FOR_CACHING, + ) + prerequisites = struct( additional_inputs = additional_inputs, bin_dir = feature_configuration._bin_dir, cc_compilation_context = merged_compilation_context, explicit_swift_module_map_file = explicit_swift_module_map_info.file, explicit_swift_module_map_inputs = explicit_swift_module_map_info.inputs, + explicit_swift_module_map_unused_inputs = explicit_swift_module_map_info.unused_inputs, genfiles_dir = feature_configuration._genfiles_dir, indexstore_directory = indexstore_directory, is_swift = True, @@ -355,6 +423,9 @@ def compile_module_interface( target_label = feature_configuration._label, transitive_modules = transitive_modules, transitive_swift_dependency_inputs = transitive_swift_dependency_inputs_list, + transitive_swiftinterfaces = transitive_swiftinterfaces, + transitive_swiftmodules_only = transitive_swiftmodules_only, + use_swiftinterface_for_caching = use_swiftinterface_for_caching, user_compile_flags = copts, ) @@ -677,10 +748,24 @@ def compile( ) defines_set = sets.make(defines) + + # See the matching loop in `compile_module_interface` for the rationale. + # `private_swiftinterface` is preferred, matching + # `transitive_swift_dependency_inputs`. + transitive_swiftinterfaces = [] + transitive_swiftmodules_only = [] for module in transitive_modules: swift_module = module.swift if not swift_module: continue + interface_file = ( + swift_module.private_swiftinterface or + swift_module.swiftinterface + ) + if interface_file: + transitive_swiftinterfaces.append(interface_file) + if type(swift_module.swiftmodule) == "File": + transitive_swiftmodules_only.append(swift_module.swiftmodule) if swift_module.defines: defines_set = sets.union( defines_set, @@ -783,6 +868,13 @@ to use swift_common.compile(include_dev_srch_paths = ...) instead.\ upcoming_features, experimental_features = upcoming_and_experimental_features( feature_configuration = feature_configuration, ) + + # Determine if we should use swiftinterface files for caching + use_swiftinterface_for_caching = is_feature_enabled( + feature_configuration = feature_configuration, + feature_name = SWIFT_FEATURE_USE_SWIFTINTERFACE_FOR_CACHING, + ) + prerequisites = struct( additional_inputs = additional_inputs + toolchains.cc.all_files.to_list(), always_include_headers = is_feature_enabled( @@ -800,6 +892,7 @@ to use swift_common.compile(include_dev_srch_paths = ...) instead.\ experimental_features = experimental_features, explicit_swift_module_map_file = explicit_swift_module_map_info.file, explicit_swift_module_map_inputs = explicit_swift_module_map_info.inputs, + explicit_swift_module_map_unused_inputs = explicit_swift_module_map_info.unused_inputs, genfiles_dir = feature_configuration._genfiles_dir, include_dev_srch_paths = include_dev_srch_paths_value, is_swift = True, @@ -811,7 +904,10 @@ to use swift_common.compile(include_dev_srch_paths = ...) instead.\ target_label = feature_configuration._label, transitive_modules = transitive_modules, transitive_swift_dependency_inputs = transitive_swift_dependency_inputs_list, + transitive_swiftinterfaces = transitive_swiftinterfaces, + transitive_swiftmodules_only = transitive_swiftmodules_only, upcoming_features = upcoming_features, + use_swiftinterface_for_caching = use_swiftinterface_for_caching, user_compile_flags = copts, workspace_name = workspace_name, # Merge the compile outputs into the prerequisites. diff --git a/swift/internal/feature_names.bzl b/swift/internal/feature_names.bzl index 85936c5a0..b98b573a1 100644 --- a/swift/internal/feature_names.bzl +++ b/swift/internal/feature_names.bzl @@ -442,3 +442,18 @@ SWIFT_FEATURE_ENABLE_EMBEDDED = "swift.enable_embedded" # Before swift 6.3 using macros lead to absolute paths in swiftmodule files # even with -prefix-serialized-debugging-options SWIFT_FEATURE__SUPPORTS_HERMETIC_SWIFTMODULE = "swift._supports_hermetic_swiftmodule" + +# If enabled, the action cache key for Swift compilation will be based on +# `.swiftinterface` files instead of `.swiftmodule` files. This allows upstream +# libraries to make internal changes without triggering recompilation of +# downstream dependencies, as long as their public interface remains stable. +# +# This feature requires all dependencies to be built with `library_evolution` +# enabled (so that `.swiftinterface` files are generated). If a dependency does +# not have a `.swiftinterface` file, the build will fall back to using its +# `.swiftmodule` file for that specific dependency. +# +# NOTE: This feature uses Bazel's `unused_inputs_list` mechanism to exclude +# `.swiftmodule` files from the action cache key while still providing them as +# inputs to the Swift compiler. +SWIFT_FEATURE_USE_SWIFTINTERFACE_FOR_CACHING = "swift.use_swiftinterface_for_caching" diff --git a/swift/toolchains/config/action_config.bzl b/swift/toolchains/config/action_config.bzl index e0ce6f557..a43f15319 100644 --- a/swift/toolchains/config/action_config.bzl +++ b/swift/toolchains/config/action_config.bzl @@ -122,7 +122,8 @@ def _config_result_init( *, additional_tools = [], inputs = [], - transitive_inputs = []): + transitive_inputs = [], + unused_inputs = []): """Validates and initializes an action configurator result. Args: @@ -132,6 +133,12 @@ def _config_result_init( being configured. transitive_inputs: A list of `depset`s of `File`s that should be passed as inputs to the action being configured. + unused_inputs: A list of `File`s that should be passed as inputs to the + action but should NOT affect the action cache key. These files are + written to an `unused_inputs_list` file that is passed to the action. + This is useful for files that are needed by the compiler but whose + content changes should not trigger recompilation (e.g., swiftmodule + files when using swiftinterface for caching). Returns: A new config result that can be returned from a configurator. @@ -140,6 +147,7 @@ def _config_result_init( "additional_tools": additional_tools, "inputs": inputs, "transitive_inputs": transitive_inputs, + "unused_inputs": unused_inputs, } def add_arg(arg_name_or_value, value = None, format = None): @@ -194,6 +202,7 @@ ConfigResultInfo, _config_result_init_unchecked = provider( "additional_tools", # List[depset[File]] "inputs", # list[File] "transitive_inputs", # List[depset[File]] + "unused_inputs", # list[File] - inputs that don't affect cache key ], init = _config_result_init, ) diff --git a/swift/toolchains/config/compile_config.bzl b/swift/toolchains/config/compile_config.bzl index 9ef945d0c..354450aed 100644 --- a/swift/toolchains/config/compile_config.bzl +++ b/swift/toolchains/config/compile_config.bzl @@ -2152,6 +2152,19 @@ def _dependencies_swiftmodules_and_swiftdocs_configurator(prerequisites, args): uniquify = True, ) + # When `swift.use_swiftinterface_for_caching` is enabled and there is at + # least one dep with a swiftinterface, route swiftinterfaces into + # cache-key `inputs` and swiftmodules into `unused_inputs` (still + # sandboxed for the compiler, but excluded from the action cache key). + # This lets an internal-only change in an upstream module leave + # downstream compile actions cache-eligible. + use_caching = getattr(prerequisites, "use_swiftinterface_for_caching", False) + interfaces = getattr(prerequisites, "transitive_swiftinterfaces", []) + if use_caching and interfaces: + return ConfigResultInfo( + inputs = interfaces + prerequisites.direct_swiftdocs, + unused_inputs = getattr(prerequisites, "transitive_swiftmodules_only", []), + ) return ConfigResultInfo( inputs = prerequisites.transitive_swift_dependency_inputs + prerequisites.direct_swiftdocs, @@ -2166,6 +2179,14 @@ def _dependencies_swiftmodules_configurator(prerequisites, args): uniquify = True, ) + # See _dependencies_swiftmodules_and_swiftdocs_configurator for context. + use_caching = getattr(prerequisites, "use_swiftinterface_for_caching", False) + interfaces = getattr(prerequisites, "transitive_swiftinterfaces", []) + if use_caching and interfaces: + return ConfigResultInfo( + inputs = interfaces, + unused_inputs = getattr(prerequisites, "transitive_swiftmodules_only", []), + ) return ConfigResultInfo( inputs = prerequisites.transitive_swift_dependency_inputs, ) @@ -2282,6 +2303,15 @@ def _explicit_swift_module_map_configurator( return ConfigResultInfo( inputs = inputs, transitive_inputs = transitive_inputs, + # When `swift.use_swiftinterface_for_caching` is enabled, + # `_explicit_swift_module_map_info` already partitions the dep + # swiftmodules out of `inputs` into `unused_inputs`; propagate that + # partition verbatim here. + unused_inputs = getattr( + prerequisites, + "explicit_swift_module_map_unused_inputs", + [], + ), ) def _module_name_configurator(prerequisites, args): diff --git a/test/BUILD b/test/BUILD index 7c6c97493..d6b62f108 100644 --- a/test/BUILD +++ b/test/BUILD @@ -31,6 +31,7 @@ load(":split_derived_files_tests.bzl", "split_derived_files_test_suite") load(":swift_binary_linking_tests.bzl", "swift_binary_linking_test_suite") load(":swift_through_non_swift_tests.bzl", "swift_through_non_swift_test_suite") load(":swift_toolchain_tests.bzl", "swift_toolchain_test_suite") +load(":swiftinterface_for_caching_tests.bzl", "swiftinterface_for_caching_test_suite") load(":symbol_graphs_tests.bzl", "symbol_graphs_test_suite") load(":synthesize_interface_tests.bzl", "synthesize_interface_test_suite") load(":utils_tests.bzl", "utils_test_suite") @@ -92,6 +93,8 @@ swift_through_non_swift_test_suite(name = "swift_through_non_swift") swift_toolchain_test_suite(name = "swift_toolchain") +swiftinterface_for_caching_test_suite(name = "swiftinterface_for_caching") + symbol_graphs_test_suite(name = "symbol_graphs") synthesize_interface_test_suite( diff --git a/test/fixtures/swiftinterface_for_caching/BUILD b/test/fixtures/swiftinterface_for_caching/BUILD new file mode 100644 index 000000000..287978a6f --- /dev/null +++ b/test/fixtures/swiftinterface_for_caching/BUILD @@ -0,0 +1,67 @@ +load("//swift:swift_library.bzl", "swift_library") +load("//test/fixtures:common.bzl", "FIXTURE_TAGS") + +package( + default_testonly = True, + default_visibility = ["//test:__subpackages__"], +) + +licenses(["notice"]) + +# A dependency library built with `library_evolution`, so it emits a +# `.swiftinterface` in addition to a `.swiftmodule`. Consumers of this library +# can key their action cache on the swiftinterface when the +# `swift.use_swiftinterface_for_caching` feature is enabled. +swift_library( + name = "upstream_lib", + srcs = ["UpstreamLib.swift"], + library_evolution = True, + module_name = "UpstreamLib", + tags = FIXTURE_TAGS, +) + +# A dependency library built WITHOUT `library_evolution`, so it does not emit +# a `.swiftinterface`. Used to exercise the mixed-graph fallback: even with the +# `swift.use_swiftinterface_for_caching` feature enabled, this library's +# `.swiftmodule` still enters downstream compile actions' cache-key inputs +# to preserve correctness. +swift_library( + name = "upstream_lib_no_evolution", + srcs = ["UpstreamLibNoEvolution.swift"], + library_evolution = False, + module_name = "UpstreamLibNoEvolution", + tags = FIXTURE_TAGS, +) + +# Downstream client that depends only on the library-evolution dep. Its +# SwiftCompile action is the primary target of the tests: with the feature on, +# `UpstreamLib.swiftinterface` participates in the cache key while the +# `.swiftmodule` is provided via `unused_inputs_list`. +swift_library( + name = "downstream_client", + srcs = ["DownstreamClient.swift"], + library_evolution = True, + module_name = "DownstreamClient", + tags = FIXTURE_TAGS, + deps = [":upstream_lib"], +) + +# Downstream client with a mixed dep set: one library-evolution dep and one +# non-library-evolution dep. Used to verify fallback behavior. +# +# Note: `library_evolution` is intentionally NOT set on this target. Swift +# refuses to let a library_evolution module import a non-library_evolution +# module (binary compatibility can't be guaranteed), so a mixed-dep client +# must not be library_evolution itself. This does not affect what the test +# verifies — the feature's fallback path is exercised by the shape of the +# transitive Swift deps, not by whether the current target has an interface. +swift_library( + name = "downstream_client_mixed", + srcs = ["DownstreamClientMixed.swift"], + module_name = "DownstreamClientMixed", + tags = FIXTURE_TAGS, + deps = [ + ":upstream_lib", + ":upstream_lib_no_evolution", + ], +) diff --git a/test/fixtures/swiftinterface_for_caching/DownstreamClient.swift b/test/fixtures/swiftinterface_for_caching/DownstreamClient.swift new file mode 100644 index 000000000..609b82702 --- /dev/null +++ b/test/fixtures/swiftinterface_for_caching/DownstreamClient.swift @@ -0,0 +1,6 @@ +import UpstreamLib + +public func downstreamGreeting(name: String) -> String { + let greeting = UpstreamGreeting(name: name) + return greeting.message() +} diff --git a/test/fixtures/swiftinterface_for_caching/DownstreamClientMixed.swift b/test/fixtures/swiftinterface_for_caching/DownstreamClientMixed.swift new file mode 100644 index 000000000..37e423cf2 --- /dev/null +++ b/test/fixtures/swiftinterface_for_caching/DownstreamClientMixed.swift @@ -0,0 +1,10 @@ +import UpstreamLib +import UpstreamLibNoEvolution + +public func downstreamMixed(name: String) -> Int { + let greeting = UpstreamGreeting(name: name) + _ = greeting.message() + var counter = UpstreamCounter() + counter.advance() + return counter.count +} diff --git a/test/fixtures/swiftinterface_for_caching/UpstreamLib.swift b/test/fixtures/swiftinterface_for_caching/UpstreamLib.swift new file mode 100644 index 000000000..e83faa506 --- /dev/null +++ b/test/fixtures/swiftinterface_for_caching/UpstreamLib.swift @@ -0,0 +1,11 @@ +public struct UpstreamGreeting { + public let name: String + + public init(name: String) { + self.name = name + } + + public func message() -> String { + return "Hello, \(name)!" + } +} diff --git a/test/fixtures/swiftinterface_for_caching/UpstreamLibNoEvolution.swift b/test/fixtures/swiftinterface_for_caching/UpstreamLibNoEvolution.swift new file mode 100644 index 000000000..4be9f9059 --- /dev/null +++ b/test/fixtures/swiftinterface_for_caching/UpstreamLibNoEvolution.swift @@ -0,0 +1,11 @@ +public struct UpstreamCounter { + public private(set) var count: Int + + public init() { + self.count = 0 + } + + public mutating func advance() { + count += 1 + } +} diff --git a/test/swiftinterface_for_caching_tests.bzl b/test/swiftinterface_for_caching_tests.bzl new file mode 100644 index 000000000..bd2f9ab00 --- /dev/null +++ b/test/swiftinterface_for_caching_tests.bzl @@ -0,0 +1,148 @@ +# Copyright 2026 The Bazel Authors. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for the `swift.use_swiftinterface_for_caching` feature.""" + +load("@bazel_skylib//rules:build_test.bzl", "build_test") +load( + "//test/rules:action_inputs_test.bzl", + "make_action_inputs_test_rule", +) + +# `action_inputs_test` variant with `swift.use_swiftinterface_for_caching` +# enabled (`enable_library_evolution` / `emit_swiftinterface` are enabled by +# default in this repo's toolchain, but we make them explicit here so the +# tests remain valid if toolchain defaults change). +# NOTE: Only the new feature is toggled via `config_settings`. Whether each +# fixture library emits a swiftinterface is decided per-target by the +# `library_evolution` attribute on `swift_library` (which enables +# `swift.emit_swiftinterface` / `swift.emit_private_swiftinterface` +# automatically). Putting `swift.emit_swiftinterface` here would enable it +# for *every* target under test — including `upstream_lib_no_evolution` — +# defeating the fallback test. +_use_swiftinterface_for_caching_inputs_test = make_action_inputs_test_rule( + config_settings = { + "//command_line_option:features": [ + "swift.use_swiftinterface_for_caching", + ], + }, +) + +# Control variant with the feature explicitly disabled. +_baseline_inputs_test = make_action_inputs_test_rule( + config_settings = { + "//command_line_option:features": [ + "-swift.use_swiftinterface_for_caching", + ], + }, +) + +def swiftinterface_for_caching_test_suite(name, tags = []): + """Test suite for the `swift.use_swiftinterface_for_caching` feature. + + The feature moves each dependency's `.swiftmodule` from a compile action's + cache-key `inputs` onto an `unused_inputs_list`, so downstream compile + actions can be cached against the more-stable `.swiftinterface` of an + upstream `library_evolution` dependency. Both files remain in the action's + sandbox — only the cache-key membership changes. + + Bazel's Starlark analysis-test API does not expose an action's + `unused_inputs_list` for direct assertion. Instead we verify the observable + consequences of the feature: + + 1. The action still receives every dependency artifact it needs to compile + (swiftinterface + swiftmodule remain in `inputs`), so builds do not + regress when the feature is enabled. + 2. When a dependency is not built with `library_evolution` (and therefore + has no `.swiftinterface`), that dependency's `.swiftmodule` is not + demoted — it stays in `inputs` so correctness is preserved in mixed + graphs. + 3. A `build_test` sanity check: with the feature on, the fixture builds + end-to-end. + + Args: + name: The base name to be used in targets created by this macro. + tags: Additional tags to apply to each test. + """ + all_tags = [name] + tags + + # NOTE: A `swift_library` with `library_evolution = True` enables both + # `swift.emit_swiftinterface` and `swift.emit_private_swiftinterface`, so + # the dependency produces `UpstreamLib.swiftinterface` AND + # `UpstreamLib.private.swiftinterface`. `transitive_swift_dependency_inputs` + # prefers `private_swiftinterface` when both are available, so it is the + # `.private.swiftinterface` file that ends up in downstream action inputs. + + # 1. With the feature enabled, the downstream compile action still lists + # the upstream's swiftinterface AND swiftmodule among its inputs. The + # swiftmodule appearance in `inputs` is expected — with + # `unused_inputs_list`, files are still declared as action inputs (so + # they land in the sandbox); Bazel simply excludes them from the action + # cache key. See `swift/internal/actions.bzl`. + _use_swiftinterface_for_caching_inputs_test( + name = "{}_downstream_sees_upstream_swiftinterface_when_feature_on".format(name), + tags = all_tags, + mnemonic = "SwiftCompile", + expected_inputs = [ + "UpstreamLib.private.swiftinterface", + "UpstreamLib.swiftmodule", + ], + target_under_test = "//test/fixtures/swiftinterface_for_caching:downstream_client", + ) + + # 2. Fallback in mixed graphs: `upstream_lib_no_evolution` has no + # swiftinterface, so its swiftmodule must remain in the cache-key + # inputs (via `transitive_swift_dependency_inputs`'s fallback branch). + # The library-evolution dep's swiftinterface still shows up in inputs. + _use_swiftinterface_for_caching_inputs_test( + name = "{}_no_evolution_dep_falls_back_to_swiftmodule".format(name), + tags = all_tags, + mnemonic = "SwiftCompile", + expected_inputs = [ + "UpstreamLib.private.swiftinterface", + "UpstreamLibNoEvolution.swiftmodule", + ], + target_under_test = "//test/fixtures/swiftinterface_for_caching:downstream_client_mixed", + ) + + # 3. Baseline (feature off): the swiftmodule is still an input, and so is + # the swiftinterface (the compiler receives the same file set in both + # modes). This contrast documents that our tests do not accidentally + # depend on feature-on vs feature-off *input* set changes — the + # difference is only in cache-key membership. + _baseline_inputs_test( + name = "{}_baseline_feature_off_inputs".format(name), + tags = all_tags, + mnemonic = "SwiftCompile", + expected_inputs = [ + "UpstreamLib.private.swiftinterface", + "UpstreamLib.swiftmodule", + ], + target_under_test = "//test/fixtures/swiftinterface_for_caching:downstream_client", + ) + + # 4. Smoke test: the fixture builds end-to-end with the feature enabled. + build_test( + name = "{}_build_smoke".format(name), + targets = [ + "//test/fixtures/swiftinterface_for_caching:downstream_client", + "//test/fixtures/swiftinterface_for_caching:downstream_client_mixed", + ], + tags = all_tags, + ) + + native.test_suite( + name = name, + tags = all_tags, + )