diff --git a/Sources/JExtractSwiftLib/ImportedDecls.swift b/Sources/JExtractSwiftLib/ImportedDecls.swift index 33c4e793..3df8b932 100644 --- a/Sources/JExtractSwiftLib/ImportedDecls.swift +++ b/Sources/JExtractSwiftLib/ImportedDecls.swift @@ -234,6 +234,22 @@ package final class ImportedNominalType: ImportedDecl { genericArguments: substitutions, ) } + + /// Checks if this type, or any of types it inherits from, conforms to the passed in protocol. + package func conformsTo(_ protocolName: String, in importedTypes: [String: ImportedNominalType]) -> Bool { + var visited: Set = [] + var queue: [ImportedNominalType] = [self] + while let current = queue.popLast() { + for inherited in current.inheritedTypes { + guard let name = inherited.asNominalTypeDeclaration?.name else { continue } + if name == protocolName { return true } + if let next = importedTypes[name], visited.insert(ObjectIdentifier(next)).inserted { + queue.append(next) + } + } + } + return false + } } struct SpecializationError: Error { diff --git a/Sources/JExtractSwiftLib/Swift2JavaVisitor.swift b/Sources/JExtractSwiftLib/Swift2JavaVisitor.swift index 275b48af..668760c7 100644 --- a/Sources/JExtractSwiftLib/Swift2JavaVisitor.swift +++ b/Sources/JExtractSwiftLib/Swift2JavaVisitor.swift @@ -34,7 +34,7 @@ final class Swift2JavaVisitor { private struct DeferredConstrainedExtension { var node: ExtensionDeclSyntax var sourceFilePath: String - var constraints: [(String, String)] + var constraints: [ParsedWhereConstraint] } private var deferredConstrainedExtensions: [DeferredConstrainedExtension] = [] @@ -124,48 +124,53 @@ final class Swift2JavaVisitor { return } - switch parseWhereConstraints(node.genericWhereClause) { - case .none: - break - case .unsupported: + guard let constraints = parseWhereConstraints(node.genericWhereClause) else { log.debug( "Skip importing constrained extension '\(node.extendedType.trimmedDescription)'; unsupported where-clause requirements: \(node.genericWhereClause?.trimmedDescription ?? "")" ) return - case .sameType(let whereConstraints): - let matchingSpecializations = findMatchingSpecializations( - extendedType: importedNominalType, - whereConstraints: whereConstraints, - ) - if matchingSpecializations.isEmpty { - // Specializations may not exist yet — defer for later - deferredConstrainedExtensions.append( - .init( - node: node, - sourceFilePath: sourceFilePath, - constraints: whereConstraints - ) - ) - return - } + } - // Visit members in each matching specialization, not the base type - for specialized in matchingSpecializations { - for memberItem in node.memberBlock.members { - self.visit(decl: memberItem.decl, in: specialized, sourceFilePath: sourceFilePath) - } + guard !constraints.isEmpty else { + // The extension is unconstrained: add to the base type (visible through all specializations) + importedNominalType.inheritedTypes += + node.inheritanceClause?.inheritedTypes.compactMap { + try? SwiftType($0.type, lookupContext: translator.lookupContext) + } ?? [] + for memberItem in node.memberBlock.members { + self.visit(decl: memberItem.decl, in: importedNominalType, sourceFilePath: sourceFilePath) } return } - // Unconstrained extension — add to the base type (visible through all specializations) - importedNominalType.inheritedTypes += - node.inheritanceClause?.inheritedTypes.compactMap { - try? SwiftType($0.type, lookupContext: translator.lookupContext) - } ?? [] + let hasConformanceConstraint = constraints.contains { if case .conformance = $0 { true } else { false } } - for memberItem in node.memberBlock.members { - self.visit(decl: memberItem.decl, in: importedNominalType, sourceFilePath: sourceFilePath) + // Conformance requirements depend on inheritedTypes that may be populated + // by an `extension Fish: Animal {}` later in the same file: always defer. + if hasConformanceConstraint { + deferredConstrainedExtensions.append( + .init(node: node, sourceFilePath: sourceFilePath, constraints: constraints) + ) + return + } + + let matchingSpecializations = findMatchingSpecializations( + extendedType: importedNominalType, + whereConstraints: constraints, + ) + if matchingSpecializations.isEmpty { + // Specializations may not exist yet: defer for later + deferredConstrainedExtensions.append( + .init(node: node, sourceFilePath: sourceFilePath, constraints: constraints) + ) + return + } + + // Visit members in each matching specialization, not the base type + for specialized in matchingSpecializations { + for memberItem in node.memberBlock.members { + self.visit(decl: memberItem.decl, in: specialized, sourceFilePath: sourceFilePath) + } } } @@ -613,32 +618,47 @@ final class Swift2JavaVisitor { // ==== ----------------------------------------------------------------------- // MARK: Constrained extension merging - private enum ParsedWhereConstraints { - case none - case sameType([(String, String)]) - case unsupported + private enum ParsedWhereConstraint { + case sameType(first: String, second: String) + case conformance(typeParam: String, proto: String) } - private func parseWhereConstraints(_ whereClause: GenericWhereClauseSyntax?) -> ParsedWhereConstraints { - guard let whereClause else { return .none } - var constraints: [(String, String)] = [] + /// Returns list of where requirements -- empty if unconstrained; or nil if failed to parse/handle the constraints. + private func parseWhereConstraints(_ whereClause: GenericWhereClauseSyntax?) -> [ParsedWhereConstraint]? { + guard let whereClause else { return [] } + var constraints: [ParsedWhereConstraint] = [] for requirement in whereClause.requirements { switch requirement.requirement { case .sameTypeRequirement(let sameType): - let lhs = sameType.leftType.trimmedDescription - let rhs = sameType.rightType.trimmedDescription - constraints.append((lhs, rhs)) - case .conformanceRequirement, .layoutRequirement: - return .unsupported + let first = sameType.leftType.trimmedDescription + let second = sameType.rightType.trimmedDescription + constraints.append(.sameType(first: first, second: second)) + + case .conformanceRequirement(let conformance): + let typeParam = conformance.leftType.trimmedDescription + if let composition = conformance.rightType.as(CompositionTypeSyntax.self) { + for element in composition.elements { + constraints.append( + .conformance(typeParam: typeParam, proto: element.type.trimmedDescription) + ) + } + } else { + constraints.append( + .conformance(typeParam: typeParam, proto: conformance.rightType.trimmedDescription) + ) + } + + case .layoutRequirement: + return nil } } - return .sameType(constraints) + return constraints } /// Find specializations whose type args match the given where-clause constraints private func findMatchingSpecializations( extendedType: ImportedNominalType, - whereConstraints: [(String, String)], + whereConstraints: [ParsedWhereConstraint], ) -> [ImportedNominalType] { guard let specializations = translator.specializations[extendedType] else { return [] @@ -649,15 +669,29 @@ final class Swift2JavaVisitor { } /// Check if where clause constraints match a specialization's generic arguments. - /// Same-type where-clauses are conjunctive: every constraint must hold. + /// Where-clauses are conjunctive: every constraint must hold. private func constraintsMatchSpecialization( - _ constraints: [(String, String)], + _ constraints: [ParsedWhereConstraint], specialized: ImportedNominalType, ) -> Bool { - for (lhs, rhs) in constraints { - if specialized.genericArguments[lhs] == rhs { continue } - if specialized.genericArguments[rhs] == lhs { continue } - return false + for constraint in constraints { + switch constraint { + case .sameType(let first, let second): + if specialized.genericArguments[first] == second { continue } + if specialized.genericArguments[second] == first { continue } + return false + + case .conformance(let typeParam, let proto): + guard let concreteName = specialized.genericArguments[typeParam] else { + return false + } + guard let concreteType = translator.importedTypes[concreteName] else { + return false + } + guard concreteType.conformsTo(proto, in: translator.importedTypes) else { + return false + } + } } return true } diff --git a/Tests/JExtractSwiftTests/JNI/JNIGenericTypeTests.swift b/Tests/JExtractSwiftTests/JNI/JNIGenericTypeTests.swift index cdc712ed..b0b3c430 100644 --- a/Tests/JExtractSwiftTests/JNI/JNIGenericTypeTests.swift +++ b/Tests/JExtractSwiftTests/JNI/JNIGenericTypeTests.swift @@ -300,7 +300,7 @@ struct JNIGenericTypeTests { public var contents: T } - extension Tank where T: Animal { // currently not supported yet + extension Tank where T: Animal { public func feed() {} } extension Tank where T == Fish { @@ -321,10 +321,10 @@ struct JNIGenericTypeTests { expectedChunks: [ "public final class FishTank implements JNISwiftInstance {", "observeTheFish", + "feed", ], notExpectedChunks: [ - "feed", - "useTheTool", + "useTheTool" ], ) } @@ -379,4 +379,235 @@ struct JNIGenericTypeTests { ], ) } + + @Test("Conformance constraint with no matching conformance is dropped") + func conformanceConstraintWithoutConformanceIsDropped() throws { + let input = + #""" + public struct Fish { + public var name: String + } + + public protocol Animal {} + + public struct Tank { + public var contents: T + } + + extension Tank where T: Animal { + public func feed() {} + } + + public typealias FishTank = Tank + """# + + try assertOutput( + input: input, + .jni, + .java, + detectChunkByInitialLines: 1, + expectedChunks: [ + "public final class FishTank implements JNISwiftInstance {" + ], + notExpectedChunks: [ + "feed" + ], + ) + } + + @Test("Transitive conformance through protocol refinement is honored") + func transitiveConformanceIsHonored() throws { + let input = + #""" + public struct Fish { + public var name: String + } + + public protocol Animal {} + public protocol AquaticAnimal: Animal {} + extension Fish: AquaticAnimal {} + + public struct Tank { + public var contents: T + } + + extension Tank where T: Animal { + public func feed() {} + } + + public typealias FishTank = Tank + """# + + try assertOutput( + input: input, + .jni, + .java, + detectChunkByInitialLines: 1, + expectedChunks: [ + "public final class FishTank implements JNISwiftInstance {", + "feed", + ], + ) + } + + @Test("Multi-conformance extensions require all conformances to match") + func multiConformanceExtensionsRequireAllToMatch() throws { + let input = + #""" + public struct Fish { + public var name: String + } + + public protocol Animal {} + public protocol Edible {} + extension Fish: Animal {} + + public struct Tank { + public var contents: T + } + + extension Tank where T: Animal, T: Edible { + // Fish is Animal but not Edible, so this must be dropped + public func cookAndServe() {} + } + + public typealias FishTank = Tank + """# + + try assertOutput( + input: input, + .jni, + .java, + detectChunkByInitialLines: 1, + expectedChunks: [ + "public final class FishTank implements JNISwiftInstance {" + ], + notExpectedChunks: [ + "cookAndServe" + ], + ) + } + + @Test("Composition constraint flattens to multiple requirements") + func compositionConstraintFlattens() throws { + let input = + #""" + public struct Fish { + public var name: String + } + public struct Salmon { + public var name: String + } + + public protocol Animal {} + public protocol Edible {} + extension Fish: Animal {} + extension Salmon: Animal {} + extension Salmon: Edible {} + + public struct Tank { + public var contents: T + } + + extension Tank where T: Animal & Edible { + public func cookAndServe() {} + } + + public typealias FishTank = Tank + public typealias SalmonTank = Tank + """# + + // Salmon conforms to both, so SalmonTank gets the method. + // Fish only conforms to Animal, so FishTank does not get the method, + // but the FishTank class is still generated. + try assertOutput( + input: input, + .jni, + .java, + detectChunkByInitialLines: 1, + expectedChunks: [ + "public final class FishTank implements JNISwiftInstance {", + "public final class SalmonTank implements JNISwiftInstance {", + "cookAndServe", + ], + ) + } + + @Test("Mixed same-type + conformance constraints") + func mixedSameTypeAndConformanceConstraints() throws { + let input = + #""" + public struct Fish { + public var name: String + } + public struct Tool { + public var name: String + } + + public protocol Animal {} + extension Fish: Animal {} + + public struct Pair { + public var first: A + public var second: B + } + + extension Pair where A == Int, B: Animal { + // FishPair (Int, Fish): matches both. ToolPair (Int, Tool): B not Animal. + public func describe() {} + } + + public typealias FishPair = Pair + public typealias ToolPair = Pair + """# + + try assertOutput( + input: input, + .jni, + .java, + detectChunkByInitialLines: 1, + expectedChunks: [ + "public final class FishPair implements JNISwiftInstance {", + "public final class ToolPair implements JNISwiftInstance {", + "describe", + ], + ) + } + + @Test("Conformance can be added after the constrained extension in same file") + func conformanceDeclaredAfterConstrainedExtension() throws { + let input = + #""" + public struct Fish { + public var name: String + } + + public protocol Animal {} + + public struct Tank { + public var contents: T + } + + // Constrained extension comes BEFORE the conformance declaration: + // matching must defer until after the input is fully visited. + extension Tank where T: Animal { + public func feed() {} + } + + public typealias FishTank = Tank + + extension Fish: Animal {} + """# + + try assertOutput( + input: input, + .jni, + .java, + detectChunkByInitialLines: 1, + expectedChunks: [ + "public final class FishTank implements JNISwiftInstance {", + "feed", + ], + ) + } }