Skip to content

Commit bf3a6ed

Browse files
Merge pull request #1463 from shilei365/fix/generic-typealias-parameter-substitution
Fix: substitute generic type parameters when resolving typealiases
2 parents 5155755 + df36cd9 commit bf3a6ed

2 files changed

Lines changed: 127 additions & 1 deletion

File tree

SourceryRuntime/Sources/Common/Composer/ParserResultsComposed.swift

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -661,6 +661,70 @@ internal struct ParserResultsComposed {
661661
let set = typeName.set.map { SetType(name: $0.name, elementTypeName: $0.elementTypeName, elementType: $0.elementType) }
662662
set?.name = aliased.name
663663

664+
// When the typealias is generic (e.g. `typealias Foo<T> = Bar<T, Never>`),
665+
// substitute the typealias definition's generic placeholders with the
666+
// concrete type arguments from the usage site.
667+
let resolvedGeneric: GenericType?
668+
if let aliasTypealias = aliased.typealias,
669+
let aliasGeneric = aliasTypealias.typeName.generic,
670+
let usageSiteGeneric = typeName.generic {
671+
// Build a mapping from the typealias's placeholder names to the
672+
// concrete types at the usage site.
673+
// e.g. typealias CurrentValuePublisher<Value> = AnyPublisher<Value, Never>
674+
// usage: CurrentValuePublisher<[MyDataModel]?>
675+
// → mapping: ["Value": "[MyDataModel]?"]
676+
//
677+
// The alias name (e.g. "CurrentValuePublisher") has the same number
678+
// of generic parameters as the usage site. To find the placeholder
679+
// names we look at the alias definition's *own* generic parameter
680+
// list. Sourcery stores the alias `typeName` as the RHS
681+
// (e.g. `AnyPublisher<Value, Never>`), so the placeholder names
682+
// appear as type-parameter names in the RHS that match the LHS
683+
// parameter names. Since Sourcery does not store the LHS generic
684+
// parameter list on `Typealias`, we infer them: any type-parameter
685+
// in the RHS generic that is NOT a known type is a placeholder.
686+
//
687+
// A simpler and more reliable heuristic: pair up the usage-site
688+
// parameters with the alias name's parameters positionally —
689+
// both have the same arity.
690+
var placeholderMapping = [String: TypeName]()
691+
for (index, usageParam) in usageSiteGeneric.typeParameters.enumerated() {
692+
// The placeholder name at this position is inferred from the
693+
// alias RHS by checking which RHS parameters are *not* concrete
694+
// types. However the most robust approach is to note that the
695+
// LHS generic parameter at position `index` corresponds to the
696+
// usage-site parameter at the same position.
697+
//
698+
// We scan the RHS generic for parameters whose typeName matches
699+
// no known type and build the mapping.
700+
// For safety, just iterate and map positionally if within bounds.
701+
if index < aliasGeneric.typeParameters.count {
702+
let rhsParam = aliasGeneric.typeParameters[index]
703+
let rhsName = rhsParam.typeName.unwrappedTypeName
704+
// Check if this RHS parameter is a placeholder (not a known type)
705+
if typeMap[rhsName] == nil && resolvedTypealiases[rhsName] == nil {
706+
placeholderMapping[rhsName] = usageParam.typeName
707+
}
708+
}
709+
}
710+
711+
if !placeholderMapping.isEmpty {
712+
// Substitute placeholders in the RHS generic type parameters
713+
let substitutedParams = aliasGeneric.typeParameters.map { param -> GenericTypeParameter in
714+
let paramName = param.typeName.unwrappedTypeName
715+
if let concreteTypeName = placeholderMapping[paramName] {
716+
return GenericTypeParameter(typeName: concreteTypeName, type: param.type)
717+
}
718+
return param
719+
}
720+
resolvedGeneric = GenericType(name: aliasGeneric.name, typeParameters: substitutedParams)
721+
} else {
722+
resolvedGeneric = aliasGeneric
723+
}
724+
} else {
725+
resolvedGeneric = aliased.typealias?.typeName.generic ?? generic
726+
}
727+
664728
return TypeName(name: aliased.name,
665729
isOptional: typeName.isOptional,
666730
isImplicitlyUnwrappedOptional: typeName.isImplicitlyUnwrappedOptional,
@@ -669,7 +733,7 @@ internal struct ParserResultsComposed {
669733
dictionary: aliased.typealias?.typeName.dictionary ?? dictionary,
670734
closure: aliased.typealias?.typeName.closure ?? typeName.closure,
671735
set: aliased.typealias?.typeName.set ?? set,
672-
generic: aliased.typealias?.typeName.generic ?? generic
736+
generic: resolvedGeneric
673737
)
674738
}
675739

SourceryTests/Parsing/ComposerSpec.swift

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1407,6 +1407,68 @@ class ParserComposerSpec: QuickSpec {
14071407
}
14081408
}
14091409

1410+
context("given generic typealias") {
1411+
it("substitutes placeholder type parameters with concrete types from usage site") {
1412+
let code = """
1413+
typealias MyPublisher<Value> = Result<Value, Never>
1414+
protocol DataPublishing {
1415+
var itemsPublisher: MyPublisher<[String]?> { get }
1416+
}
1417+
"""
1418+
let types = parse(code)
1419+
let proto = types.first(where: { $0.name == "DataPublishing" })
1420+
let variable = proto?.variables.first
1421+
1422+
// actualTypeName should resolve to Result<[String]?, Never>
1423+
let actualGeneric = variable?.typeName.actualTypeName?.generic
1424+
expect(actualGeneric?.name).to(equal("Result"))
1425+
expect(actualGeneric?.typeParameters).to(haveCount(2))
1426+
// First type parameter should be the concrete type from usage site, not the placeholder "Value"
1427+
expect(actualGeneric?.typeParameters.first?.typeName.name).to(equal("[String]?"))
1428+
// Second type parameter should remain "Never" (it's a concrete type in the typealias RHS)
1429+
expect(actualGeneric?.typeParameters.last?.typeName.name).to(equal("Never"))
1430+
}
1431+
1432+
it("substitutes multiple placeholder type parameters with concrete types") {
1433+
let code = """
1434+
typealias MyResult<Success, Failure> = Result<Success, Failure>
1435+
protocol Service {
1436+
var result: MyResult<String, Error> { get }
1437+
}
1438+
"""
1439+
let types = parse(code)
1440+
let proto = types.first(where: { $0.name == "Service" })
1441+
let variable = proto?.variables.first
1442+
1443+
let actualGeneric = variable?.typeName.actualTypeName?.generic
1444+
expect(actualGeneric?.name).to(equal("Result"))
1445+
expect(actualGeneric?.typeParameters).to(haveCount(2))
1446+
expect(actualGeneric?.typeParameters.first?.typeName.name).to(equal("String"))
1447+
expect(actualGeneric?.typeParameters.last?.typeName.name).to(equal("Error"))
1448+
}
1449+
1450+
it("does not substitute concrete types that exist in the type map") {
1451+
let code = """
1452+
struct Never {}
1453+
typealias MyPublisher<Value> = Result<Value, Never>
1454+
protocol Publishing {
1455+
var publisher: MyPublisher<Int> { get }
1456+
}
1457+
"""
1458+
let types = parse(code)
1459+
let proto = types.first(where: { $0.name == "Publishing" })
1460+
let variable = proto?.variables.first
1461+
1462+
let actualGeneric = variable?.typeName.actualTypeName?.generic
1463+
expect(actualGeneric?.name).to(equal("Result"))
1464+
expect(actualGeneric?.typeParameters).to(haveCount(2))
1465+
// "Value" placeholder should be substituted with "Int"
1466+
expect(actualGeneric?.typeParameters.first?.typeName.name).to(equal("Int"))
1467+
// "Never" is a known type, should NOT be substituted
1468+
expect(actualGeneric?.typeParameters.last?.typeName.name).to(equal("Never"))
1469+
}
1470+
}
1471+
14101472
context("given method parameter") {
14111473
it("replaces method parameter type alias with actual type") {
14121474
let expectedMethodParameter = MethodParameter(name: "foo", index: 0, typeName: TypeName(name: "FooAlias", actualTypeName: TypeName(name: "Foo")), type: Class(name: "Foo"))

0 commit comments

Comments
 (0)