diff --git a/Sources/SwiftExtract/AnalysisResult.swift b/Sources/SwiftExtract/AnalysisResult.swift index b995ec1bd..259882a96 100644 --- a/Sources/SwiftExtract/AnalysisResult.swift +++ b/Sources/SwiftExtract/AnalysisResult.swift @@ -19,14 +19,21 @@ public struct AnalysisResult { public var extractedGlobalVariables: [ExtractedFunc] public var extractedGlobalFuncs: [ExtractedFunc] + /// Extensions the analyzed module declares on nominal types from other modules. + /// + /// Separate from `extractedTypes`, since we're not declaring types here at all, just extensions. + public var crossModuleExtensions: CrossModuleExtensions + public init( extractedTypes: [SwiftTypeName: ExtractedNominalType], extractedGlobalVariables: [ExtractedFunc], - extractedGlobalFuncs: [ExtractedFunc] + extractedGlobalFuncs: [ExtractedFunc], + crossModuleExtensions: CrossModuleExtensions = CrossModuleExtensions() ) { self.extractedTypes = extractedTypes self.extractedGlobalVariables = extractedGlobalVariables self.extractedGlobalFuncs = extractedGlobalFuncs + self.crossModuleExtensions = crossModuleExtensions } /// Expands variadic functions into distinct overloads. @@ -41,3 +48,156 @@ public struct AnalysisResult { } } } + +// ==== ----------------------------------------------------------------------- +// MARK: Conformance queries + +/// A conformance the analysis observed, either stated directly or inherited +public struct ObservedConformance { + /// How this conformance was derived + public enum Derivation: Equatable { + /// An inheritance clause named the protocol directly + case stated + /// Reached from a stated conformance by inheritance. + case inherited(via: [SwiftNominalIdentity]) + } + + /// The conforming nominal + public let type: SwiftNominalIdentity + + /// The protocol it conforms to + public let protocolType: SwiftNominalIdentity + + public let derivation: Derivation + + /// Requirements guarding the *stated* conformance this was derived from. + /// Empty when the conformance is unconditional. + public let requirements: [SwiftGenericRequirement] + + /// True when the guarding `where` clause held a requirement the analyzer could not + /// represent, so `requirements` is an incomplete picture. + /// + /// This can happen when a not-extracted type is used in a requirement. + public let hasUnrepresentableRequirements: Bool + + public init( + type: SwiftNominalIdentity, + protocolType: SwiftNominalIdentity, + derivation: Derivation, + requirements: [SwiftGenericRequirement], + hasUnrepresentableRequirements: Bool = false + ) { + self.type = type + self.protocolType = protocolType + self.derivation = derivation + self.requirements = requirements + self.hasUnrepresentableRequirements = hasUnrepresentableRequirements + } + + /// Whether this conformance applies only under a `where` clause + public var isConditional: Bool { + !requirements.isEmpty || hasUnrepresentableRequirements + } +} + +extension AnalysisResult { + + /// Every nominal the analysis observed to conform to `protocolType`. + public func typesConforming(to protocolType: SwiftNominalIdentity) -> [ObservedConformance] { + var results: [ObservedConformance] = [] + var refinementCache: [SwiftNominalIdentity: [SwiftNominalIdentity]?] = [:] + + /// The protocols walked from `stated` up to and including `protocolType`, or nil when + /// `stated` does not refine it. Empty when `stated` *is* `protocolType` + func refinementPath(from stated: SwiftNominalIdentity) -> [SwiftNominalIdentity]? { + if let cached = refinementCache[stated] { return cached } + // Seed the cache before recursing so a cyclic inheritance clause in malformed + // input cannot spin forever + refinementCache[stated] = .some(nil) + var path: [SwiftNominalIdentity]? = nil + if stated == protocolType { + path = [] + } else { + for parent in inheritedProtocolIdentities(of: stated) { + if let rest = refinementPath(from: parent) { + path = [parent] + rest + break + } + } + } + refinementCache[stated] = .some(path) + return path + } + + func consider( + conformer: SwiftNominalIdentity, + stated: SwiftNominalIdentity, + requirements: [SwiftGenericRequirement], + hasUnrepresentableRequirements: Bool + ) { + guard let path = refinementPath(from: stated) else { return } + // `path` ends at `protocolType`. The reportable chain starts at what source + // actually named and stops short of the protocol being asked about + let via = Array(([stated] + path).dropLast()) + results.append( + ObservedConformance( + type: conformer, + protocolType: protocolType, + derivation: path.isEmpty ? .stated : .inherited(via: via), + requirements: requirements, + hasUnrepresentableRequirements: hasUnrepresentableRequirements + ) + ) + } + + // Types this module declares, from their own inheritance clauses + for name in extractedTypes.keys.sorted() { + guard let type = extractedTypes[name], !type.swiftNominal.isProtocolKind else { continue } + let conformer = type.identity + for stated in protocolIdentities(in: type.inheritedTypes) { + consider( + conformer: conformer, + stated: stated, + requirements: [], + hasUnrepresentableRequirements: false + ) + } + } + + // Other module's types, from the extensions that gave them conformances + for record in crossModuleExtensions.all { + for stated in protocolIdentities(in: record.addedConformances) { + consider( + conformer: record.extendedType, + stated: stated, + requirements: record.requirements, + hasUnrepresentableRequirements: record.hasUnrepresentableRequirements + ) + } + } + + return results + } + + /// The protocols `protocolType` refines, as far as this analysis can see them. + /// + /// Resolved through `extractedTypes`. + private func inheritedProtocolIdentities(of protocolType: SwiftNominalIdentity) -> [SwiftNominalIdentity] { + guard let declared = extractedTypes[protocolType.qualifiedName] else { return [] } + return protocolIdentities(in: declared.inheritedTypes) + } + + private func protocolIdentities(in types: [SwiftType]) -> [SwiftNominalIdentity] { + types.compactMap { type in + guard let decl = type.asNominalTypeDeclaration, decl.isProtocolKind else { return nil } + return decl.identity + } + } +} + +extension SwiftNominalTypeDeclaration { + /// Whether this declaration is a protocol + var isProtocolKind: Bool { + kind == .protocol + } +} diff --git a/Sources/SwiftExtract/CrossModuleExtensions.swift b/Sources/SwiftExtract/CrossModuleExtensions.swift new file mode 100644 index 000000000..3a1ade38e --- /dev/null +++ b/Sources/SwiftExtract/CrossModuleExtensions.swift @@ -0,0 +1,157 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift.org project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift.org project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import SwiftSyntax + +/// The members a single extension contributes to the type it extends +public struct CrossModuleExtensionMembers { + public var initializers: [ExtractedFunc] + public var methods: [ExtractedFunc] + public var variables: [ExtractedFunc] + + public init( + initializers: [ExtractedFunc] = [], + methods: [ExtractedFunc] = [], + variables: [ExtractedFunc] = [] + ) { + self.initializers = initializers + self.methods = methods + self.variables = variables + } + + public var isEmpty: Bool { + initializers.isEmpty && methods.isEmpty && variables.isEmpty + } +} + +// ==== ----------------------------------------------------------------------- +// MARK: A cross-module extension + +/// One `extension` the analyzed sources declare on a nominal type from another module +public struct CrossModuleExtension { + /// The other module's nominal being extended + public let extendedType: SwiftNominalIdentity + + /// The extension declaration syntax + public let syntax: ExtensionDeclSyntax + + /// Protocols this extension adds, resolved. + public let addedConformances: [SwiftType] + + /// Members this extension contributes + public let members: CrossModuleExtensionMembers + + /// The `where` clause requirements, resolved. + /// + /// Empty for an unconditional extension, unless `hasUnrepresentableRequirements` in which case + /// this collection is missing the unrepresentable requirements. + public let requirements: [SwiftGenericRequirement] + + /// True when the `where` clause held a requirement this analyzer cannot represent + /// (a layout requirement, or one whose types did not resolve). + public let hasUnrepresentableRequirements: Bool + + /// The extension's own attributes, including e.g. `@available` among them. + public let attributes: AttributeListSyntax + + public let sourceFilePath: String + + public init( + extendedType: SwiftNominalIdentity, + syntax: ExtensionDeclSyntax, + addedConformances: [SwiftType], + members: CrossModuleExtensionMembers, + requirements: [SwiftGenericRequirement], + hasUnrepresentableRequirements: Bool = false, + attributes: AttributeListSyntax, + sourceFilePath: String + ) { + self.extendedType = extendedType + self.syntax = syntax + self.addedConformances = addedConformances + self.members = members + self.requirements = requirements + self.hasUnrepresentableRequirements = hasUnrepresentableRequirements + self.attributes = attributes + self.sourceFilePath = sourceFilePath + } + + /// Whether the conformances this extension adds apply only under its `where` clause + public var isConditional: Bool { + !requirements.isEmpty || hasUnrepresentableRequirements + } +} + +/// An extension whose extended type could not be resolved at all. +/// +/// A diagnostics channel rather than a worklist. These were previously discarded with +/// no trace, which made an unresolvable import indistinguishable from an empty extension +public struct UnresolvedExtension { + /// The extended type as written, e.g. "SomeUnknownModule.Widget" + public let extendedTypeDescription: String + public let syntax: ExtensionDeclSyntax + public let sourceFilePath: String + + public init(extendedTypeDescription: String, syntax: ExtensionDeclSyntax, sourceFilePath: String) { + self.extendedTypeDescription = extendedTypeDescription + self.syntax = syntax + self.sourceFilePath = sourceFilePath + } +} + +/// The cross-module extensions found by one analysis. +/// +/// A module cannot declare a nominal type owned by another module, so an extension is +/// the only way the analyzed sources can add conformances or members to another module's +/// type. +public struct CrossModuleExtensions { + /// Every recorded cross-module extension, in the order encountered + public private(set) var all: [CrossModuleExtension] = [] + + /// Extensions whose extended type never resolved + public private(set) var unresolved: [UnresolvedExtension] = [] + + /// Index into `all`, keyed by extended type + private var byExtendedType: [SwiftNominalIdentity: [Int]] = [:] + + public init() {} + + public var isEmpty: Bool { + all.isEmpty && unresolved.isEmpty + } + + public mutating func record(_ extension: CrossModuleExtension) { + byExtendedType[`extension`.extendedType, default: []].append(all.count) + all.append(`extension`) + } + + public mutating func record(unresolved unresolvedExtension: UnresolvedExtension) { + unresolved.append(unresolvedExtension) + } + + /// Every cross-module extension on `type`, in the order encountered + public func extensions(of type: SwiftNominalIdentity) -> [CrossModuleExtension] { + (byExtendedType[type] ?? []).map { all[$0] } + } + + /// Every other module's nominal that some cross-module extension extends, in a stable order + public var extendedTypes: [SwiftNominalIdentity] { + var seen: Set = [] + var ordered: [SwiftNominalIdentity] = [] + for record in all where seen.insert(record.extendedType).inserted { + ordered.append(record.extendedType) + } + return ordered + } +} diff --git a/Sources/SwiftExtract/ExtractedDecls.swift b/Sources/SwiftExtract/ExtractedDecls.swift index f6bf3788b..6b821a815 100644 --- a/Sources/SwiftExtract/ExtractedDecls.swift +++ b/Sources/SwiftExtract/ExtractedDecls.swift @@ -137,6 +137,15 @@ public final class ExtractedNominalType: ExtractedSwiftDecl { return swiftNominal.qualifiedTypeName } + /// This type's module-qualified identity, keyed on the output-facing name so a + /// specialization identifies as itself ("FishBox") rather than as its base ("Box"). + /// + /// `identity.qualifiedName` is therefore `effectiveTypeName`, the key this type is + /// registered under in `AnalysisResult.extractedTypes` + public var identity: SwiftNominalIdentity { + SwiftNominalIdentity(moduleName: swiftNominal.moduleName, typeName: effectiveOutputTypeName) + } + /// The effective Swift-side type name used as a registration key in the /// analyzer's type table - "FishBox" for a specialization registered via /// `typealias FishBox = Box`, the qualified base name (e.g. "Box") diff --git a/Sources/SwiftExtract/SwiftAnalysisVisitor.swift b/Sources/SwiftExtract/SwiftAnalysisVisitor.swift index 1939bf592..8f9d003cc 100644 --- a/Sources/SwiftExtract/SwiftAnalysisVisitor.swift +++ b/Sources/SwiftExtract/SwiftAnalysisVisitor.swift @@ -132,7 +132,33 @@ final class SwiftAnalysisVisitor { // 'extension' in a nominal type is invalid. Ignore return } - guard let extractedNominalType = analyzer.extractedNominalType(node.extendedType) else { + + let extractedNominalType: ExtractedNominalType + switch analyzer.resolveExtendedType(node.extendedType) { + case .extractable(let extracted): + extractedNominalType = extracted + + case .otherModule(let otherModuleDecl): + recordCrossModuleExtension(node, extending: otherModuleDecl, sourceFilePath: sourceFilePath) + return + + case .rejected: + return + + case .unresolved: + self.reportSkipped( + node, + name: "extension \(node.extendedType.trimmedDescription)", + sourceFilePath: sourceFilePath, + reason: "extended type '\(node.extendedType.trimmedDescription)' did not resolve" + ) + analyzer.crossModuleExtensions.record( + unresolved: UnresolvedExtension( + extendedTypeDescription: node.extendedType.trimmedDescription, + syntax: node, + sourceFilePath: sourceFilePath, + ) + ) return } @@ -186,6 +212,130 @@ final class SwiftAnalysisVisitor { } } + // ==== ----------------------------------------------------------------------- + // MARK: Cross-module extensions + + /// Record an extension on a nominal owned by another module. + private func recordCrossModuleExtension( + _ node: ExtensionDeclSyntax, + extending otherModuleDecl: SwiftNominalTypeDeclaration, + sourceFilePath: String, + ) { + let addedConformances = + node.inheritanceClause?.inheritedTypes.compactMap { + try? SwiftType($0.type, lookupContext: analyzer.lookupContext) + } ?? [] + let (requirements, hasUnrepresentable) = resolveExtensionRequirements( + node.genericWhereClause, + of: "\(otherModuleDecl.moduleName).\(otherModuleDecl.qualifiedName)" + ) + + log.debug( + "Record cross-module extension of '\(otherModuleDecl.moduleName).\(otherModuleDecl.qualifiedName)'" + + " adding [\(addedConformances.map(\.description).joined(separator: ", "))]" + ) + + analyzer.crossModuleExtensions.record( + CrossModuleExtension( + extendedType: otherModuleDecl.identity, + syntax: node, + addedConformances: addedConformances, + members: captureCrossModuleMembers(of: node, extending: otherModuleDecl, sourceFilePath: sourceFilePath), + requirements: requirements, + hasUnrepresentableRequirements: hasUnrepresentable, + attributes: node.attributes, + sourceFilePath: sourceFilePath, + ) + ) + } + + /// Collect the members a cross-module extension adds + private func captureCrossModuleMembers( + of node: ExtensionDeclSyntax, + extending otherModuleDecl: SwiftNominalTypeDeclaration, + sourceFilePath: String, + ) -> CrossModuleExtensionMembers { + guard + let scratch = try? ExtractedNominalType( + swiftNominal: otherModuleDecl, + lookupContext: analyzer.lookupContext + ) + else { + return CrossModuleExtensionMembers() + } + + for memberItem in node.memberBlock.members { + switch memberItem.decl.as(DeclSyntaxEnum.self) { + case .functionDecl(let functionNode): + self.visit(functionDecl: functionNode, in: scratch, sourceFilePath: sourceFilePath) + case .variableDecl(let variableNode): + self.visit(variableDecl: variableNode, in: scratch, sourceFilePath: sourceFilePath) + case .initializerDecl(let initializerNode): + self.visit(initializerDecl: initializerNode, in: scratch, sourceFilePath: sourceFilePath) + case .subscriptDecl(let subscriptNode): + self.visit(subscriptDecl: subscriptNode, in: scratch, sourceFilePath: sourceFilePath) + default: + log.debug("Skip member of cross-module extension of '\(otherModuleDecl.qualifiedName)': \(memberItem.decl.kind)") + } + } + + return CrossModuleExtensionMembers( + initializers: scratch.initializers, + methods: scratch.methods, + variables: scratch.variables, + ) + } + + /// Resolve an extension's `where` clause into the requirements. + private func resolveExtensionRequirements( + _ whereClause: GenericWhereClauseSyntax?, + of extendedTypeDescription: String + ) -> (requirements: [SwiftGenericRequirement], hasUnrepresentable: Bool) { + guard let whereClause else { return ([], false) } + + var requirements: [SwiftGenericRequirement] = [] + var hasUnrepresentable = false + + /// A requirement we cannot represent makes `requirements` an incomplete picture of + /// what guards the conformance, so say which one and why + func reportUnrepresentable(_ requirementNode: some SyntaxProtocol, reason: String, hint: Bool) { + let message = + "Unrepresentable requirement '\(requirementNode.trimmedDescription)'" + + " in extension of '\(extendedTypeDescription)'; \(reason)" + self.log.warning(hint ? self.makeMissingTypeMessage(message) : message) + hasUnrepresentable = true + } + + for requirementNode in whereClause.requirements { + switch requirementNode.requirement { + case .conformanceRequirement(let conformance): + if let lhs = try? SwiftType(conformance.leftType, lookupContext: analyzer.lookupContext), + let rhs = try? SwiftType(conformance.rightType, lookupContext: analyzer.lookupContext) + { + requirements.append(.inherits(lhs, rhs)) + } else { + reportUnrepresentable(conformance, reason: "a type in it did not resolve", hint: true) + } + + case .sameTypeRequirement(let sameType): + if let leftNode = sameType.leftType.as(TypeSyntax.self), + let rightNode = sameType.rightType.as(TypeSyntax.self), + let lhs = try? SwiftType(leftNode, lookupContext: analyzer.lookupContext), + let rhs = try? SwiftType(rightNode, lookupContext: analyzer.lookupContext) + { + requirements.append(.equals(lhs, rhs)) + } else { + reportUnrepresentable(sameType, reason: "a type in it did not resolve", hint: true) + } + + case .layoutRequirement(let layout): + // The shared requirement vocabulary has no layout case + reportUnrepresentable(layout, reason: "layout requirements are not supported", hint: false) + } + } + return (requirements, hasUnrepresentable) + } + func visit( functionDecl node: FunctionDeclSyntax, in typeContext: ExtractedNominalType?, @@ -751,7 +901,24 @@ final class SwiftAnalysisVisitor { sourceFilePath: String, error: any Error ) { - let message = "Failed to import: \(name) in module '\(analyzer.swiftModuleName)'; \(error)" + self.reportSkipped( + node, + name: name, + sourceFilePath: sourceFilePath, + reason: "\(error)", + error: error + ) + } + + /// Same, for skips that are not driven by a thrown error + func reportSkipped( + _ node: some SyntaxProtocol, + name: String, + sourceFilePath: String, + reason: String, + error: (any Error)? = nil + ) { + let message = "Failed to import: \(name) in module '\(analyzer.swiftModuleName)'; \(reason)" self.log.warning(self.makeMissingTypeMessage(message)) analyzer.diagnosticsSink?.emit( SwiftExtractDiagnostic( diff --git a/Sources/SwiftExtract/SwiftAnalyzer.swift b/Sources/SwiftExtract/SwiftAnalyzer.swift index fa158cc04..5e73c7cb3 100644 --- a/Sources/SwiftExtract/SwiftAnalyzer.swift +++ b/Sources/SwiftExtract/SwiftAnalyzer.swift @@ -56,6 +56,12 @@ public final class SwiftAnalyzer { /// type representation. package var extractedTypes: [SwiftTypeName: ExtractedNominalType] = [:] + /// Extensions these sources declare on nominal types owned by other modules. + /// + /// Kept apart from `extractedTypes` on purpose: recording another module's type here says + /// what the analysis learned about it, never that a generator should emit it + package var crossModuleExtensions: CrossModuleExtensions = CrossModuleExtensions() + /// Specializations of generic types that will get their concrete Java declarations, "as if" they were independent types package var specializations: [ExtractedNominalType: Set] = [:] @@ -126,6 +132,7 @@ extension SwiftAnalyzer { extractedTypes: self.extractedTypes, extractedGlobalVariables: self.extractedGlobalVariables, extractedGlobalFuncs: self.extractedGlobalFuncs, + crossModuleExtensions: self.crossModuleExtensions, ) } @@ -392,29 +399,65 @@ extension SwiftAnalyzer { return self.extractedNominalType(nominal) } - /// Try to resolve the given nominal type node into its extracted representation. - func extractedNominalType( - _ typeNode: TypeSyntax - ) -> ExtractedNominalType? { - guard let swiftType = try? SwiftType(typeNode, lookupContext: lookupContext) else { - return nil + /// The outcome of resolving the type an `extension` extends. + enum ExtendedTypeResolution { + /// Resolved, owned by this analysis, and passed every filter + case extractable(ExtractedNominalType) + /// Resolved, but owned by another module. + case otherModule(SwiftNominalTypeDeclaration) + /// Resolved, but either filtered out explicitly + case rejected + /// Failed to resolve, the type is not known + case unresolved + } + + /// Whether this analysis owns `decl` for the purpose of emitting bindings for it. + func ownsForEmission(_ decl: SwiftNominalTypeDeclaration) -> Bool { + let isFromThisModule = decl.moduleName == self.swiftModuleName + let isFromStubbedModule = config.hasImportedModuleStub(moduleOfNominal: decl.moduleName) + let isFromDependencyModule = sourceDependencies.swiftModuleNames.contains(decl.moduleName) + return isFromThisModule || isFromStubbedModule || isFromDependencyModule + } + + /// Resolve the type an extension extends, reporting *why* resolution ended where it did + func resolveExtendedType(_ typeNode: TypeSyntax) -> ExtendedTypeResolution { + guard let swiftType = try? SwiftType(typeNode, lookupContext: lookupContext), + let swiftNominalDecl = swiftType.asNominalTypeDeclaration + else { + return .unresolved } - guard let swiftNominalDecl = swiftType.asNominalTypeDeclaration else { - return nil + + guard ownsForEmission(swiftNominalDecl) else { + return .otherModule(swiftNominalDecl) } - let isFromThisModule = swiftNominalDecl.moduleName == self.swiftModuleName - let isFromStubbedModule = config.hasImportedModuleStub(moduleOfNominal: swiftNominalDecl.moduleName) - let isFromDependencyModule = sourceDependencies.swiftModuleNames.contains(swiftNominalDecl.moduleName) - guard isFromThisModule || isFromStubbedModule || isFromDependencyModule else { - return nil + guard + swiftNominalDecl.syntax.shouldExtract( + config: config, + in: nil as ExtractedNominalType?, + decider: extractDecider + ) + else { + return .rejected } - guard swiftNominalDecl.syntax.shouldExtract(config: config, in: nil as ExtractedNominalType?, decider: extractDecider) else { - return nil + guard let extracted = extractedNominalType(swiftNominalDecl) else { + return .rejected } + return .extractable(extracted) + } - return extractedNominalType(swiftNominalDecl) + /// Try to resolve the given nominal type node into its extracted representation. + /// + /// Yields a type only when this analysis both owns it and intends to emit it, which is + /// what callers outside the extension path require + func extractedNominalType( + _ typeNode: TypeSyntax + ) -> ExtractedNominalType? { + guard case .extractable(let extracted) = resolveExtendedType(typeNode) else { + return nil + } + return extracted } func extractedNominalType(_ nominal: SwiftNominalTypeDeclaration) -> ExtractedNominalType? { diff --git a/Sources/SwiftExtract/SwiftTypes/SwiftFunctionSignature.swift b/Sources/SwiftExtract/SwiftTypes/SwiftFunctionSignature.swift index 34a94163b..bbfa23a63 100644 --- a/Sources/SwiftExtract/SwiftTypes/SwiftFunctionSignature.swift +++ b/Sources/SwiftExtract/SwiftTypes/SwiftFunctionSignature.swift @@ -545,9 +545,7 @@ extension VariableDeclSyntax { return [.get] } - // Account for private(set) and similar modifiers. This is checked before the - // accessor block, because a variable can restrict its setter's access level and - // still spell out its accessors explicitly. + // Account for private(set) and similar modifiers. for modifier in self.modifiers where modifier.detail?.detail.text == "set" { if !minimumAccessLevel.matches(modifier) { return [.get] diff --git a/Sources/SwiftExtract/SwiftTypes/SwiftNominalTypeDeclaration.swift b/Sources/SwiftExtract/SwiftTypes/SwiftNominalTypeDeclaration.swift index 31a076648..0f9e81074 100644 --- a/Sources/SwiftExtract/SwiftTypes/SwiftNominalTypeDeclaration.swift +++ b/Sources/SwiftExtract/SwiftTypes/SwiftNominalTypeDeclaration.swift @@ -14,6 +14,39 @@ import SwiftSyntax +// ==== ----------------------------------------------------------------------- +// MARK: Nominal identity + +/// A module-qualified identity for a nominal type. +public struct SwiftNominalIdentity: Hashable, Sendable, CustomStringConvertible { + /// The module that declares this nominal, e.g. "Swift" + public let moduleName: String + + /// The parent-chained name within that module, e.g. `GeneratedContent.Kind` + public let typeName: SwiftQualifiedTypeName + + public init(moduleName: String, typeName: SwiftQualifiedTypeName) { + self.moduleName = moduleName + self.typeName = typeName + } + + /// e.g. "Swift.String", "MyLib.GeneratedContent.Kind" + public var fullyQualifiedName: String { + "\(moduleName).\(typeName.fullName)" + } + + /// Parent-chained but module-unqualified: the spelling `extractedTypes` is keyed by + public var qualifiedName: String { + typeName.fullName + } + + public var leafName: String { + typeName.leafName + } + + public var description: String { fullyQualifiedName } +} + /// A syntax node for a nominal type declaration. public typealias NominalTypeDeclSyntaxNode = any DeclGroupSyntax & NamedDeclSyntax & WithAttributesSyntax & WithModifiersSyntax @@ -189,6 +222,14 @@ public class SwiftNominalTypeDeclaration: SwiftTypeDeclaration { } } + /// This declaration's module-qualified identity. + /// + /// `qualifiedName` alone cannot distinguish `Swift.String` from a locally declared + /// `String`, which is why anything indexing types across module boundaries keys on this + public var identity: SwiftNominalIdentity { + SwiftNominalIdentity(moduleName: moduleName, typeName: qualifiedTypeName) + } + public var qualifiedName: String { qualifiedTypeName.fullName } diff --git a/Tests/JExtractSwiftTests/VariableImportTests.swift b/Tests/JExtractSwiftTests/VariableImportTests.swift index 8538af4d2..f2ffde174 100644 --- a/Tests/JExtractSwiftTests/VariableImportTests.swift +++ b/Tests/JExtractSwiftTests/VariableImportTests.swift @@ -154,38 +154,4 @@ final class VariableImportTests { ] ) } - - let class_privateSetWithAccessorBlockInterfaceFile = - """ - public class MySwiftClass { - public private(set) var counterInt: Int { - get { fatalError() } - set { fatalError() } - } - } - """ - - @Test("Import: public private(set) var counterInt: Int with an explicit accessor block emits only the getter") - func variable_int_privateSet_explicitAccessorBlock() throws { - try assertOutput( - input: class_privateSetWithAccessorBlockInterfaceFile, - .ffm, - .java, - swiftModuleName: "FakeModule", - detectChunkByInitialLines: 1, - expectedChunks: [ - """ - public long getCounterInt() throws SwiftIntegerOverflowException { - $ensureAlive(); - long result$checked = swiftjava_FakeModule_MySwiftClass_counterInt$get.call(this.$memorySegment()); - ... - } - """ - ], - notExpectedChunks: [ - "swiftjava_FakeModule_MySwiftClass_counterInt$set", - "setCounterInt", - ] - ) - } } diff --git a/Tests/SwiftExtractTests/CrossModuleExtensionsTests.swift b/Tests/SwiftExtractTests/CrossModuleExtensionsTests.swift new file mode 100644 index 000000000..2d32a5944 --- /dev/null +++ b/Tests/SwiftExtractTests/CrossModuleExtensionsTests.swift @@ -0,0 +1,621 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift.org project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift.org project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import SwiftExtract +import SwiftSyntax +import Testing + +/// Extensions on nominal types owned by another module used to be discarded whole: both +/// the conformances they added and the members they contributed. These verify they are +/// recorded now, and equally that recording them does not turn the other module's type +/// into an emission target. +@Suite("CrossModuleExtensions") +struct CrossModuleExtensionsSuite { + + // ==== ---------------------------------------------------------------------- + // MARK: Same-module extensions are unaffected + + @Test("Extensions from same module dont record cross-module extension") + func existingAnalysesRecordNothing() throws { + let result = try analyze( + sources: [ + ( + "/fake/Source.swift", + """ + public protocol Greetable { + func greet() -> String + } + + public struct Person: Greetable { + public func greet() -> String { "hi" } + } + + extension Person { + public func shout() -> String { "HI" } + } + """ + ) + ], + moduleName: "TestModule" + ) + + #expect(result.crossModuleExtensions.isEmpty) + #expect(result.crossModuleExtensions.all.isEmpty) + #expect(result.crossModuleExtensions.unresolved.isEmpty) + + // Present as extracted type, and extensions on it + let person = try #require(result.extractedTypes["Person"]) + #expect(person.methods.contains { $0.name == "shout" }) + #expect(person.inheritedTypes.map(\.description) == ["Greetable"]) + + // The conformance is observable through the query, attributed to this module + let greetable = SwiftNominalIdentity( + moduleName: "TestModule", + typeName: SwiftQualifiedTypeName("Greetable") + ) + let conformance = try #require( + result.typesConforming(to: greetable).first { $0.type.leafName == "Person" } + ) + #expect(conformance.type.moduleName == "TestModule") + #expect(conformance.derivation == .stated) + #expect(!conformance.isConditional) + } + + /// Extensions on the module's own types must behave exactly as before: members merged + /// onto the type, conformances merged into `inheritedTypes`, nothing recorded as + /// cross-module + @Test + func ownedTypeExtensionsAreUnaffected() throws { + let result = try analyze( + sources: [ + ( + "/fake/Source.swift", + """ + public protocol Labelable {} + public struct Person {} + extension Person: Labelable { + public func greet() -> String { "hi" } + } + """ + ) + ], + moduleName: "TestModule" + ) + + #expect(result.crossModuleExtensions.isEmpty) + let person = try #require(result.extractedTypes["Person"]) + #expect(person.methods.map(\.name) == ["greet"]) + #expect(person.inheritedTypes.map(\.description) == ["Labelable"]) + } + + /// An owned type the user filtered out stays filtered out. Ownership rejection is not + /// the same as belonging to another module, and must not be rerouted into the + /// cross-module record + @Test + func filteredOwnedTypeIsNotRecordedAsCrossModule() throws { + var config = DefaultSwiftExtractConfiguration(swiftModule: "TestModule") + config.swiftFilterExclude = ["Person"] + + let result = try analyze( + sources: [ + ( + "/fake/Source.swift", + """ + public protocol Labelable {} + public struct Person {} + extension Person: Labelable { + public func greet() -> String { "hi" } + } + """ + ) + ], + moduleName: "TestModule", + config: config + ) + + #expect(result.extractedTypes["Person"] == nil) + #expect(result.crossModuleExtensions.isEmpty) + } + + // ==== ---------------------------------------------------------------------- + // MARK: Conformance-only extensions + + @Test + func conformanceOnStdlibTypeIsRecorded() throws { + let result = try analyze( + sources: [ + ( + "/fake/Source.swift", + """ + public protocol Labelable {} + extension String: Labelable {} + """ + ) + ], + moduleName: "TestModule" + ) + + let recorded = try #require(result.crossModuleExtensions.all.first) + #expect(recorded.extendedType.fullyQualifiedName == "Swift.String") + #expect(recorded.addedConformances.map(\.description) == ["Labelable"]) + #expect(recorded.members.isEmpty) + #expect(!recorded.isConditional) + } + + /// The compatibility guarantee. Recording another module's type must not register it in + /// `extractedTypes`, which every generator treats as its emission worklist + @Test + func recordingDoesNotMakeTheOtherModuleTypeAnEmissionTarget() throws { + let result = try analyze( + sources: [ + ( + "/fake/Source.swift", + """ + public protocol Labelable {} + extension String: Labelable {} + extension Int { public func doubled() -> Int { self * 2 } } + """ + ) + ], + moduleName: "TestModule" + ) + + #expect(result.extractedTypes["String"] == nil) + #expect(result.extractedTypes["Int"] == nil) + // Only the module's own protocol is an emission target + #expect(result.extractedTypes.keys.sorted() == ["Labelable"]) + } + + @Test + func moduleSelectorSpellingResolvesTheSameWay() throws { + // `.swiftinterface` files spell other modules' types with a module selector. SwiftExtract + // ignores the selector and resolves by leaf name, which is what makes the + // FoundationModels case work at all + let result = try analyze( + sources: [ + ( + "/fake/Source.swift", + """ + public protocol Labelable {} + extension Swift::String: Labelable {} + """ + ) + ], + moduleName: "TestModule" + ) + + let recorded = try #require(result.crossModuleExtensions.all.first) + #expect(recorded.extendedType.fullyQualifiedName == "Swift.String") + } + + // ==== ---------------------------------------------------------------------- + // MARK: Member-contributing extensions + + @Test + func membersOnStdlibTypeAreRecorded() throws { + let result = try analyze( + sources: [ + ( + "/fake/Source.swift", + """ + extension Int { + public func doubled() -> Int { self * 2 } + public var isZero: Bool { self == 0 } + } + """ + ) + ], + moduleName: "TestModule" + ) + + let recorded = try #require(result.crossModuleExtensions.all.first) + #expect(recorded.extendedType.fullyQualifiedName == "Swift.Int") + #expect(recorded.addedConformances.isEmpty) + #expect(recorded.members.methods.map(\.name) == ["doubled"]) + #expect(recorded.members.variables.contains { $0.name == "isZero" }) + } + + @Test + func conformanceAndMembersAreBothRecorded() throws { + let result = try analyze( + sources: [ + ( + "/fake/Source.swift", + """ + public protocol Labelable {} + extension String: Labelable { + public func shout() -> String { self } + } + """ + ) + ], + moduleName: "TestModule" + ) + + let recorded = try #require(result.crossModuleExtensions.all.first) + #expect(recorded.addedConformances.map(\.description) == ["Labelable"]) + #expect(recorded.members.methods.map(\.name) == ["shout"]) + } + + /// Two extensions on the same other-module type both land, and are retrievable together + @Test + func multipleExtensionsOnOneTypeAccumulate() throws { + let result = try analyze( + sources: [ + ( + "/fake/Source.swift", + """ + public protocol Labelable {} + public protocol Shoutable {} + extension String: Labelable {} + extension String: Shoutable {} + """ + ) + ], + moduleName: "TestModule" + ) + + let string = SwiftNominalIdentity(moduleName: "Swift", typeName: SwiftQualifiedTypeName("String")) + let onString = result.crossModuleExtensions.extensions(of: string) + #expect(onString.count == 2) + #expect(onString.flatMap { $0.addedConformances.map(\.description) } == ["Labelable", "Shoutable"]) + #expect(result.crossModuleExtensions.extendedTypes.map(\.fullyQualifiedName) == ["Swift.String"]) + } + + // ==== ---------------------------------------------------------------------- + // MARK: Conditional extensions + + /// A conditional conformance on another module's generic. Recording happens before the + /// `where`-clause handling that governs specialization matching, so this survives, and + /// it is marked conditional so a consumer cannot mistake it for unconditional + @Test + func conditionalConformanceOnStdlibGenericIsRecordedAsConditional() throws { + let result = try analyze( + sources: [ + ( + "/fake/Source.swift", + """ + public protocol Labelable {} + extension Array: Labelable where Element: Labelable {} + """ + ) + ], + moduleName: "TestModule" + ) + + let recorded = try #require(result.crossModuleExtensions.all.first) + #expect(recorded.extendedType.fullyQualifiedName == "Swift.Array") + #expect(recorded.addedConformances.map(\.description) == ["Labelable"]) + #expect(recorded.isConditional) + #expect(!recorded.requirements.isEmpty) + // A conformance-constrained extension on an *owned* type gets deferred and can reach + // `extractedTypes` through the specialization flush. A type from another module returns + // before the deferral, so that path must stay unreachable for it + #expect(result.extractedTypes["Array"] == nil) + } + + @Test + func conditionalConformanceOnOptionalIsRecorded() throws { + let result = try analyze( + sources: [ + ( + "/fake/Source.swift", + """ + public protocol Labelable {} + extension Optional: Labelable where Wrapped: Labelable {} + """ + ) + ], + moduleName: "TestModule" + ) + + let recorded = try #require(result.crossModuleExtensions.all.first) + #expect(recorded.extendedType.fullyQualifiedName == "Swift.Optional") + #expect(recorded.isConditional) + } + + @Test + func requirementWithUnresolvableTypeIsConditionalWithoutRequirements() throws { + let result = try analyze( + sources: [ + ( + "/fake/Source.swift", + """ + public protocol Labelable {} + extension Array: Labelable where Element: SomeProtocolThatDoesNotExist {} + """ + ) + ], + moduleName: "TestModule" + ) + + let recorded = try #require(result.crossModuleExtensions.all.first) + #expect(recorded.extendedType.fullyQualifiedName == "Swift.Array") + #expect(recorded.requirements.isEmpty) + #expect(recorded.hasUnrepresentableRequirements) + #expect(recorded.isConditional) + + // And it stays conditional when read back through the query + let labelable = SwiftNominalIdentity( + moduleName: "TestModule", + typeName: SwiftQualifiedTypeName("Labelable") + ) + let conformance = try #require( + result.typesConforming(to: labelable).first { $0.type.leafName == "Array" } + ) + #expect(conformance.hasUnrepresentableRequirements) + #expect(conformance.isConditional) + } + + // ==== ---------------------------------------------------------------------- + // MARK: Foundation types + + @Test + func conformanceOnFoundationTypeIsRecorded() throws { + let result = try analyze( + sources: [ + ( + "/fake/Source.swift", + """ + import Foundation + public protocol Labelable {} + extension Date: Labelable {} + """ + ) + ], + moduleName: "TestModule" + ) + + let recorded = try #require(result.crossModuleExtensions.all.first) + #expect(recorded.extendedType.leafName == "Date") + #expect(recorded.extendedType.moduleName != "TestModule") + #expect(result.extractedTypes["Date"] == nil) + } + + // ==== ---------------------------------------------------------------------- + // MARK: Unresolvable extended types + + @Test + func unresolvableExtendedTypeIsRecordedSeparately() throws { + let sink = CollectingDiagnosticsSink() + let result = try analyze( + sources: [ + ( + "/fake/Source.swift", + """ + public protocol Labelable {} + extension SomeTypeThatDoesNotExist: Labelable {} + """ + ) + ], + moduleName: "TestModule", + diagnosticsSink: sink + ) + + #expect(result.crossModuleExtensions.all.isEmpty) + let unresolved = try #require(result.crossModuleExtensions.unresolved.first) + #expect(unresolved.extendedTypeDescription == "SomeTypeThatDoesNotExist") + #expect(result.extractedTypes["SomeTypeThatDoesNotExist"] == nil) + + // Make sure this is diagnosed + let diagnostic = try #require(sink.diagnostics.first) + #expect(diagnostic.kind == .skippedDeclaration) + #expect(diagnostic.declarationName == "extension SomeTypeThatDoesNotExist") + #expect(diagnostic.moduleName == "TestModule") + #expect(diagnostic.sourceFilePath == "/fake/Source.swift") + #expect(diagnostic.message.contains("did not resolve")) + #expect(diagnostic.node.is(ExtensionDeclSyntax.self)) + #expect(diagnostic.underlyingError == nil) + } + + // ==== ---------------------------------------------------------------------- + // MARK: Transitive conformance query + + @Test + func conformanceIsFoundThroughProtocolInheritance() throws { + let result = try analyze( + sources: [ + ( + "/fake/Source.swift", + """ + public protocol PromptRepresentable {} + public protocol ConvertibleToGeneratedContent: PromptRepresentable {} + public protocol Generable: ConvertibleToGeneratedContent {} + + public struct Widget: Generable {} + """ + ) + ], + moduleName: "TestModule" + ) + + let promptRepresentable = SwiftNominalIdentity( + moduleName: "TestModule", + typeName: SwiftQualifiedTypeName("PromptRepresentable") + ) + let conformers = result.typesConforming(to: promptRepresentable) + + let widget = try #require(conformers.first { $0.type.leafName == "Widget" }) + guard case .inherited(let via) = widget.derivation else { + Issue.record("expected Widget's conformance to be inherited, got \(widget.derivation)") + return + } + #expect(via.map(\.leafName) == ["Generable", "ConvertibleToGeneratedContent"]) + #expect(!widget.isConditional) + } + + @Test + func unrelatedProtocolYieldsNoConformers() throws { + let result = try analyze( + sources: [ + ( + "/fake/Source.swift", + """ + public protocol Greetable {} + public protocol Unrelated {} + public struct Person: Greetable {} + """ + ) + ], + moduleName: "TestModule" + ) + + let unrelated = SwiftNominalIdentity( + moduleName: "TestModule", + typeName: SwiftQualifiedTypeName("Unrelated") + ) + #expect(result.typesConforming(to: unrelated).isEmpty) + } + + @Test + func cyclicRefinementTerminates() throws { + let result = try analyze( + sources: [ + ( + "/fake/Source.swift", + """ + public protocol A: B {} + public protocol B: A {} + public struct Thing: A {} + """ + ) + ], + moduleName: "TestModule" + ) + + let target = SwiftNominalIdentity(moduleName: "TestModule", typeName: SwiftQualifiedTypeName("B")) + let conformers = result.typesConforming(to: target) + #expect(conformers.contains { $0.type.leafName == "Thing" }) + } + + @Test + func refiningProtocolsAreNotReportedAsConformers() throws { + let result = try analyze( + sources: [ + ( + "/fake/Source.swift", + """ + public protocol Base {} + public protocol Refined: Base {} + public struct Thing: Refined {} + """ + ) + ], + moduleName: "TestModule" + ) + + let base = SwiftNominalIdentity(moduleName: "TestModule", typeName: SwiftQualifiedTypeName("Base")) + let conformers = result.typesConforming(to: base) + #expect(conformers.contains { $0.type.leafName == "Thing" }) + #expect(!conformers.contains { $0.type.leafName == "Refined" }) + } + + @Test + func specializationConformsUnderItsOwnIdentity() throws { + let result = try analyze( + sources: [ + ( + "/fake/Source.swift", + """ + public protocol Labelable {} + public struct Fish {} + public struct Tank: Labelable {} + public typealias FishTank = Tank + """ + ) + ], + moduleName: "TestModule" + ) + + let fishTank = try #require(result.extractedTypes["FishTank"]) + #expect(fishTank.identity.fullyQualifiedName == "TestModule.FishTank") + #expect(fishTank.identity.qualifiedName == fishTank.effectiveTypeName) + // The base nominal it delegates to is still `Tank` + #expect(fishTank.swiftNominal.identity.fullyQualifiedName == "TestModule.Tank") + + let labelable = SwiftNominalIdentity( + moduleName: "TestModule", + typeName: SwiftQualifiedTypeName("Labelable") + ) + let conformers = result.typesConforming(to: labelable).map(\.type.fullyQualifiedName).sorted() + #expect(conformers == ["TestModule.FishTank", "TestModule.Tank"]) + } + + @Test + func stdlibTypeIsFoundThroughTransitiveConformance() throws { + let result = try analyze( + sources: [ + ( + "/fake/Source.swift", + """ + public protocol PromptRepresentable {} + public protocol ConvertibleToGeneratedContent: PromptRepresentable {} + public protocol Generable: ConvertibleToGeneratedContent {} + + extension String: PromptRepresentable {} + extension Int: Generable {} + """ + ) + ], + moduleName: "TestModule" + ) + + let promptRepresentable = SwiftNominalIdentity( + moduleName: "TestModule", + typeName: SwiftQualifiedTypeName("PromptRepresentable") + ) + let conformers = result.typesConforming(to: promptRepresentable) + let byName = Dictionary(uniqueKeysWithValues: conformers.map { ($0.type.fullyQualifiedName, $0) }) + + // Direct + let string = try #require(byName["Swift.String"]) + #expect(string.derivation == .stated) + #expect(!string.isConditional) + + // Two hops of protocol inheritance + let int = try #require(byName["Swift.Int"]) + guard case .inherited(let via) = int.derivation else { + Issue.record("expected Int's conformance to be inherited, got \(int.derivation)") + return + } + #expect(via.map(\.leafName) == ["Generable", "ConvertibleToGeneratedContent"]) + } + + @Test + func conditionalConformanceSurfacesAsConditionalFromTheQuery() throws { + let result = try analyze( + sources: [ + ( + "/fake/Source.swift", + """ + public protocol Labelable {} + extension String: Labelable {} + extension Array: Labelable where Element: Labelable {} + """ + ) + ], + moduleName: "TestModule" + ) + + let labelable = SwiftNominalIdentity( + moduleName: "TestModule", + typeName: SwiftQualifiedTypeName("Labelable") + ) + let conformers = result.typesConforming(to: labelable) + let byName = Dictionary(uniqueKeysWithValues: conformers.map { ($0.type.fullyQualifiedName, $0) }) + + #expect(try #require(byName["Swift.String"]).isConditional == false) + #expect(try #require(byName["Swift.Array"]).isConditional == true) + } +}