diff --git a/Sources/SourceKitBazelBSP/RequestHandlers/SKOptions/BazelTargetCompilerArgsExtractor.swift b/Sources/SourceKitBazelBSP/RequestHandlers/SKOptions/BazelTargetCompilerArgsExtractor.swift index d3f5054..9557619 100644 --- a/Sources/SourceKitBazelBSP/RequestHandlers/SKOptions/BazelTargetCompilerArgsExtractor.swift +++ b/Sources/SourceKitBazelBSP/RequestHandlers/SKOptions/BazelTargetCompilerArgsExtractor.swift @@ -228,6 +228,17 @@ final class BazelTargetCompilerArgsExtractor { } return config.mnemonic == platformInfo.topLevelParentConfig.configurationName } + // When a dependency exists under multiple top-level targets, each gets a unique + // settings transition hash (the -ST- suffix in the config mnemonic). + // The parent config stored during cquery may not match the one from aquery. + // Fall back to matching without the ST hash — same platform/arch/minOS. + if candidateActions.isEmpty { + let requestedBase = platformInfo.topLevelParentConfig.configurationName.strippingSettingsTransitionHash + candidateActions = actions.filter { + guard let config = aquery.configurations[$0.configurationID] else { return false } + return config.mnemonic.strippingSettingsTransitionHash == requestedBase + } + } let contentBeingQueried: String switch strategy { case .swiftModule(_), .cHeader: @@ -245,13 +256,12 @@ final class BazelTargetCompilerArgsExtractor { return false } } - guard candidateActions.count > 0 else { + // After all filtering, multiple candidates means different ST variants + // for the same platform/file — compiler args are effectively identical. + guard let action = candidateActions.first else { throw BazelTargetCompilerArgsExtractorError.relevantTargetActionsNotFound(contentBeingQueried) } - guard candidateActions.count == 1 else { - throw BazelTargetCompilerArgsExtractorError.multipleTargetActions(contentBeingQueried, target.id) - } - return candidateActions[0] + return action } func clearCache() { @@ -446,3 +456,17 @@ extension BazelTargetCompilerArgsExtractor { lines[idx + 1] = new } } + +extension String { + /// Strips the Bazel settings transition hash suffix from a configuration mnemonic. + /// + /// Bazel appends a `-ST-` suffix to configuration mnemonics to distinguish builds + /// that share the same platform/arch/minOS but have different build settings due to + /// different top-level targets (e.g., an app vs. an extension). The hash is deterministic + /// for a given set of settings but differs across top-level targets. + /// + /// See: https://bazel.build/extending/config#user-defined-transitions + fileprivate var strippingSettingsTransitionHash: String { + self.replacingOccurrences(of: #"-ST-[a-f0-9]+$"#, with: "", options: .regularExpression) + } +} diff --git a/Tests/SourceKitBazelBSPTests/BazelTargetCompilerArgsExtractorTests.swift b/Tests/SourceKitBazelBSPTests/BazelTargetCompilerArgsExtractorTests.swift index 51d14c5..1a76823 100644 --- a/Tests/SourceKitBazelBSPTests/BazelTargetCompilerArgsExtractorTests.swift +++ b/Tests/SourceKitBazelBSPTests/BazelTargetCompilerArgsExtractorTests.swift @@ -228,6 +228,59 @@ struct BazelTargetCompilerArgsExtractorTests { #expect(language == "objective-c") } + @Test + func swiftModuleWithDifferentSTHash() throws { + let extractor = Self.makeMockExtractor() + let testSourceUri = URI( + filePath: "/Users/user/Documents/demo-ios-project/HelloWorld/HelloWorldLib/Sources/AddTodoView.swift", + isDirectory: false + ) + // Use a different ST hash than the one in the aquery (2842469f5300). + // This simulates a dependency that exists under multiple top-level targets + // where the cquery stored a different parent's config than the aquery has. + let differentSTConfig = BazelTargetConfigurationInfo( + configurationName: "ios_sim_arm64-dbg-ios-sim_arm64-min17.0-ST-aaaaaaaaaaaa", + minimumOsVersion: "17.0", + platform: "iphonesimulator", + cpuArch: "sim_arm64", + sdkName: "iphonesimulator" + ) + let result = try extractor.extractCompilerArgs( + fromAquery: aqueryResult, + forTarget: BazelTargetPlatformInfo( + label: "//HelloWorld:HelloWorldLib", + topLevelParentLabel: "//HelloWorld:HelloWorld", + topLevelParentConfig: differentSTConfig + ), + withStrategy: .swiftModule(testSourceUri), + indexOutputPath: nil + ) + #expect(!result.isEmpty) + } + + @Test + func objcFileWithDifferentSTHash() throws { + let extractor = Self.makeMockExtractor() + let differentSTConfig = BazelTargetConfigurationInfo( + configurationName: "ios_sim_arm64-dbg-ios-sim_arm64-min17.0-ST-bbbbbbbbbbbb", + minimumOsVersion: "17.0", + platform: "iphonesimulator", + cpuArch: "sim_arm64", + sdkName: "iphonesimulator" + ) + let result = try extractor.extractCompilerArgs( + fromAquery: aqueryResult, + forTarget: BazelTargetPlatformInfo( + label: "//HelloWorld:TodoObjCSupport", + topLevelParentLabel: "//HelloWorld:HelloWorld", + topLevelParentConfig: differentSTConfig + ), + withStrategy: .cImpl("HelloWorld/TodoObjCSupport/Sources/SKObjCUtils.m", "objective-c"), + indexOutputPath: nil + ) + #expect(!result.isEmpty) + } + @Test func missingSwiftModule() throws { let extractor = Self.makeMockExtractor()