diff --git a/Package.swift b/Package.swift index 3da1f882..9671961e 100644 --- a/Package.swift +++ b/Package.swift @@ -46,6 +46,8 @@ let package = Package( .testTarget( name: "MockoloTests", dependencies: [ + .product(name: "SwiftSyntax", package: "swift-syntax"), + .product(name: "SwiftSyntaxBuilder", package: "swift-syntax"), "MockoloFramework", "MockoloTestSupportMacros", ], diff --git a/Sources/MockoloFramework/Models/Attribute.swift b/Sources/MockoloFramework/Models/Attribute.swift new file mode 100644 index 00000000..e14fdb2d --- /dev/null +++ b/Sources/MockoloFramework/Models/Attribute.swift @@ -0,0 +1,53 @@ +// +// Copyright (c) 2026. Uber Technologies +// +// 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. +// + +/// Represents an attribute attached to a declaration +struct Attribute: Hashable, CustomStringConvertible { + var description: String + + enum KnownKind: Hashable { + enum AvailableKind { + /// `@available(*, deprecated)`, `@available(*, noasync)` + case behavioral + + /// `@available(iOS 26.0, *)`, `@available(iOS, introduced: 26.0)` + case platform + } + case available(AvailableKind) + } + var kind: KnownKind? + + var isAvailable: Bool { + if case .available = kind { + return true + } + return false + } + + var isBehavioralAvailable: Bool { + if case .available(.behavioral) = kind { + return true + } + return false + } + + var isPlatformAvailable: Bool { + if case .available(.platform) = kind { + return true + } + return false + } +} diff --git a/Sources/MockoloFramework/Models/MethodModel.swift b/Sources/MockoloFramework/Models/MethodModel.swift index d3ede645..c9ffd11e 100644 --- a/Sources/MockoloFramework/Models/MethodModel.swift +++ b/Sources/MockoloFramework/Models/MethodModel.swift @@ -40,6 +40,7 @@ final class MethodModel: Model { let isStatic: Bool let isAsync: Bool let throwing: ThrowingKind + let attributes: [Attribute] let funcsWithArgsHistory: [String] let customModifiers: [String : Modifier] var modelType: ModelType { @@ -177,6 +178,7 @@ final class MethodModel: Model { isStatic: Bool, offset: Int64, length: Int64, + attributes: [Attribute] = [], funcsWithArgsHistory: [String], customModifiers: [String: Modifier], modelDescription: String?, @@ -193,6 +195,7 @@ final class MethodModel: Model { self.genericTypeParams = genericTypeParams self.genericWhereClause = genericWhereClause self.processed = processed + self.attributes = attributes self.funcsWithArgsHistory = funcsWithArgsHistory self.customModifiers = customModifiers self.modelDescription = modelDescription diff --git a/Sources/MockoloFramework/Models/NominalModel.swift b/Sources/MockoloFramework/Models/NominalModel.swift index 8b4fae44..e6ff283e 100644 --- a/Sources/MockoloFramework/Models/NominalModel.swift +++ b/Sources/MockoloFramework/Models/NominalModel.swift @@ -21,7 +21,7 @@ final class NominalModel: Model { let inheritedTypeName: String let genericWhereConstraints: [String] let type: SwiftType - let attribute: String + let attributes: [Attribute] let accessLevel: String let declKindOfMockAnnotatedBaseType: NominalTypeDeclKind let entities: [(String, Model)] @@ -39,7 +39,7 @@ final class NominalModel: Model { acl: String, declKindOfMockAnnotatedBaseType: NominalTypeDeclKind, declKind: NominalTypeDeclKind, - attributes: [String], + attributes: [Attribute], offset: Int64, inheritedTypeName: String, genericWhereConstraints: [String], @@ -58,7 +58,7 @@ final class NominalModel: Model { self.offset = offset self.inheritedTypeName = inheritedTypeName self.genericWhereConstraints = genericWhereConstraints - self.attribute = Set(attributes.filter {$0.contains(String.available)}).joined(separator: " ") + self.attributes = attributes self.accessLevel = acl self.requiresSendable = requiresSendable } @@ -70,7 +70,6 @@ final class NominalModel: Model { return applyNominalTemplate( name: name, accessLevel: accessLevel, - attribute: attribute, arguments: arguments, initParamCandidates: initParamCandidates, declaredInits: declaredInits, diff --git a/Sources/MockoloFramework/Models/ParsedEntity.swift b/Sources/MockoloFramework/Models/ParsedEntity.swift index 020e0ff5..b9fbd153 100644 --- a/Sources/MockoloFramework/Models/ParsedEntity.swift +++ b/Sources/MockoloFramework/Models/ParsedEntity.swift @@ -21,7 +21,7 @@ struct ResolvedEntity { var key: String var entity: Entity var uniqueModels: [(String, Model)] - var attributes: [String] + var attributes: [Attribute] var inheritedTypes: [String] var declaredInits: [MethodModel] { @@ -65,13 +65,15 @@ struct ResolvedEntity { func model() -> Model { let metadata = entity.metadata - let combinedAttributes = entity.entityNode.attributeDescriptions + attributes + let combinedAttributes = (entity.entityNode.parsedAttributes + attributes) + .filter(\.isAvailable) + .uniqued() return NominalModel(selfType: .init(name: metadata?.nameOverride ?? (key + "Mock")), namespaces: entity.entityNode.namespaces, acl: entity.entityNode.accessLevel, declKindOfMockAnnotatedBaseType: entity.entityNode.declKind, declKind: inheritsActorProtocol ? .actor : .class, - attributes: combinedAttributes, + attributes: Array(combinedAttributes), offset: entity.entityNode.offset, inheritedTypeName: (entity.metadata?.module?.withDot ?? "") + key, genericWhereConstraints: entity.entityNode.genericWhereConstraints, @@ -92,7 +94,7 @@ protocol EntityNode { var nameText: String { get } var mayHaveGlobalActor: Bool { get } var accessLevel: String { get } - var attributeDescriptions: [String] { get } + var parsedAttributes: [Attribute] { get } var declKind: NominalTypeDeclKind { get } var inheritedTypes: [String] { get } var genericWhereConstraints: [String] { get } @@ -102,7 +104,7 @@ protocol EntityNode { } struct EntityNodeSubContainer { - var attributes: [String] + var attributes: [Attribute] var members: [Model] var hasInit: Bool } diff --git a/Sources/MockoloFramework/Models/VariableModel.swift b/Sources/MockoloFramework/Models/VariableModel.swift index d9c4d5f2..61540d41 100644 --- a/Sources/MockoloFramework/Models/VariableModel.swift +++ b/Sources/MockoloFramework/Models/VariableModel.swift @@ -14,7 +14,7 @@ final class VariableModel: Model { let type: SwiftType? let offset: Int64 let accessLevel: String - let attributes: [String]? + let attributes: [Attribute] /// Indicates whether this model can be used as a parameter to an initializer let canBeInitParam: Bool let processed: Bool @@ -50,6 +50,7 @@ final class VariableModel: Model { storageKind: MockStorageKind, canBeInitParam: Bool, offset: Int64, + attributes: [Attribute] = [], rxTypes: [String: String]?, customModifiers: [String: Modifier]?, modelDescription: String?, @@ -65,7 +66,7 @@ final class VariableModel: Model { self.rxTypes = rxTypes self.customModifiers = customModifiers self.accessLevel = acl ?? "" - self.attributes = nil + self.attributes = attributes self.modelDescription = modelDescription self.combineType = combineType } diff --git a/Sources/MockoloFramework/Parsers/SwiftSyntaxExtensions.swift b/Sources/MockoloFramework/Parsers/SwiftSyntaxExtensions.swift index 016aff56..c6cc5edf 100644 --- a/Sources/MockoloFramework/Parsers/SwiftSyntaxExtensions.swift +++ b/Sources/MockoloFramework/Parsers/SwiftSyntaxExtensions.swift @@ -151,43 +151,45 @@ extension MemberBlockItemSyntax { return modifiers?.acl ?? "" } - func transformToModel(with encloserAcl: String, declKind: NominalTypeDeclKind, metadata: AnnotationMetadata?, processed: Bool) -> (Model, String?, Bool)? { + func transformToModel(with encloserAcl: String, declKind: NominalTypeDeclKind, metadata: AnnotationMetadata?, processed: Bool) -> (Model, [Attribute], Bool)? { if let varMember = self.decl.as(VariableDeclSyntax.self) { if validateMember(varMember.modifiers, declKind, processed: processed) { let acl = memberAcl(varMember.modifiers, encloserAcl, declKind) if let item = varMember.models(with: acl, metadata: metadata, processed: processed).first { - return (item, varMember.attributes.trimmedDescription, false) + return (item, varMember.attributes.parsedAttributes.filter(\.isPlatformAvailable), false) } } } else if let funcMember = self.decl.as(FunctionDeclSyntax.self) { if validateMember(funcMember.modifiers, declKind, processed: processed) { let acl = memberAcl(funcMember.modifiers, encloserAcl, declKind) let item = funcMember.model(with: acl, declKind: declKind, funcsWithArgsHistory: metadata?.funcsWithArgsHistory, customModifiers: metadata?.modifiers, processed: processed) - return (item, funcMember.attributes.trimmedDescription, false) + return (item, funcMember.attributes.parsedAttributes.filter(\.isPlatformAvailable), false) } } else if let subscriptMember = self.decl.as(SubscriptDeclSyntax.self) { if validateMember(subscriptMember.modifiers, declKind, processed: processed) { let acl = memberAcl(subscriptMember.modifiers, encloserAcl, declKind) let item = subscriptMember.model(with: acl, declKind: declKind, processed: processed) - return (item, subscriptMember.attributes.trimmedDescription, false) + return (item, subscriptMember.attributes.parsedAttributes.filter(\.isPlatformAvailable), false) } } else if let initMember = self.decl.as(InitializerDeclSyntax.self) { if validateInit(initMember, declKind, processed: processed) { let acl = memberAcl(initMember.modifiers, encloserAcl, declKind) let item = initMember.model(with: acl, declKind: declKind, processed: processed) - return (item, initMember.attributes.trimmedDescription, true) + return (item, initMember.attributes.parsedAttributes.filter(\.isPlatformAvailable), true) } } else if let patMember = self.decl.as(AssociatedTypeDeclSyntax.self) { let acl = memberAcl(patMember.modifiers, encloserAcl, declKind) let item = patMember.model(with: acl, declKind: declKind, overrides: metadata?.typeAliases) - return (item, patMember.attributes.trimmedDescription, false) + // Behavioral attributes are deliberately dropped: the generated typealias is referenced + // throughout the mock's infrastructure, so keeping them would spread warnings through generated code. + return (item, patMember.attributes.parsedAttributes.filter(\.isPlatformAvailable), false) } else if let taMember = self.decl.as(TypeAliasDeclSyntax.self) { let acl = memberAcl(taMember.modifiers, encloserAcl, declKind) let item = taMember.model(with: acl, declKind: declKind, overrides: metadata?.typeAliases, processed: processed) - return (item, taMember.attributes.trimmedDescription, false) + return (item, taMember.attributes.parsedAttributes.filter(\.isPlatformAvailable), false) } else if let ifMacroMember = self.decl.as(IfConfigDeclSyntax.self) { - let (item, attr, initFlag) = ifMacroMember.model(with: encloserAcl, declKind: declKind, metadata: metadata, processed: processed) - return (item, attr, initFlag) + let (item, attrs, initFlag) = ifMacroMember.model(with: encloserAcl, declKind: declKind, metadata: metadata, processed: processed) + return (item, attrs, initFlag) } return nil @@ -211,16 +213,14 @@ extension MemberBlockItemListSyntax { } func memberData(with encloserAcl: String, declKind: NominalTypeDeclKind, metadata: AnnotationMetadata?, processed: Bool) -> EntityNodeSubContainer { - var attributeList = [String]() + var attributeList = [Attribute]() var memberList = [Model]() var hasInit = false for m in self { - if let (item, attr, initFlag) = m.transformToModel(with: encloserAcl, declKind: declKind, metadata: metadata, processed: processed) { + if let (item, attrs, initFlag) = m.transformToModel(with: encloserAcl, declKind: declKind, metadata: metadata, processed: processed) { memberList.append(item) - if let attrDesc = attr { - attributeList.append(attrDesc) - } + attributeList.append(contentsOf: attrs) hasInit = hasInit || initFlag } } @@ -229,9 +229,9 @@ extension MemberBlockItemListSyntax { } extension IfConfigDeclSyntax { - func model(with encloserAcl: String, declKind: NominalTypeDeclKind, metadata: AnnotationMetadata?, processed: Bool) -> (Model, String?, Bool) { + func model(with encloserAcl: String, declKind: NominalTypeDeclKind, metadata: AnnotationMetadata?, processed: Bool) -> (Model, [Attribute], Bool) { var clauseList = [IfMacroModel.Clause]() - var attrDesc: String? + var attributes = [Attribute]() var hasInit = false for cl in self.clauses { @@ -242,10 +242,10 @@ extension IfConfigDeclSyntax { var subModels = [Model]() if let list = cl.elements?.as(MemberBlockItemListSyntax.self) { for element in list { - if let (item, attr, initFlag) = element.transformToModel(with: encloserAcl, declKind: declKind, metadata: metadata, processed: processed) { + if let (item, attrs, initFlag) = element.transformToModel(with: encloserAcl, declKind: declKind, metadata: metadata, processed: processed) { subModels.append(item) - if let attr = attr, attr.contains(String.available) { - attrDesc = attr + if !attrs.isEmpty { + attributes = attrs } hasInit = hasInit || initFlag } @@ -266,7 +266,7 @@ extension IfConfigDeclSyntax { } let macroModel = IfMacroModel(clauses: clauseList, offset: self.offset) - return (macroModel, attrDesc, hasInit) + return (macroModel, attributes, hasInit) } } @@ -303,8 +303,8 @@ extension ProtocolDeclSyntax: EntityNode { return genericWhereClause?.requirements.map { $0.with(\.trailingComma, nil).trimmedDescription } ?? [] } - var attributeDescriptions: [String] { - return attributes.descriptions + var parsedAttributes: [Attribute] { + attributes.parsedAttributes } func annotationMetadata(with annotation: String) -> AnnotationMetadata? { @@ -354,8 +354,8 @@ extension ClassDeclSyntax: EntityNode { return genericWhereClause?.requirements.map { $0.with(\.trailingComma, nil).trimmedDescription } ?? [] } - var attributeDescriptions: [String] { - return attributes.descriptions + var parsedAttributes: [Attribute] { + attributes.parsedAttributes } var isFinal: Bool { @@ -412,15 +412,47 @@ fileprivate func findNamespaces(parent: Syntax?) -> [String] { } extension AttributeListSyntax { - fileprivate var descriptions: [String] { - return compactMap { element in - guard case .attribute(let attribute) = element else { + var parsedAttributes: [Attribute] { + return compactMap { element -> Attribute? in + guard case .attribute(let attr) = element else { return nil } - return attribute.trimmedDescription + let kind: Attribute.KnownKind? + switch attr.attributeName.description { + case "available": + kind = .available(Self.isPlatformAvailability(attr: attr) ? .platform : .behavioral) + default: + kind = nil + } + return Attribute(description: attr.trimmedDescription, kind: kind) } } + private static func isPlatformAvailability(attr: AttributeSyntax) -> Bool { + guard case .availability(let args) = attr.arguments else { return false } + + switch args.first?.argument { + case .token(let token) where token.tokenKind == .binaryOperator("*"): + // Wildcard form (`@available(*, deprecated)`, `(*, noasync)`, ...) is always behavioral. + return false + case .availabilityVersionRestriction(let platformVersion) where platformVersion.version != nil: + // Version specified with a platform (`@available(iOS 26.0, *)`), always platform + return true + default: + break + } + + let platformLimitationLabels: Set = ["introduced", "obsoleted", "unavailable"] + return args.contains(where: { arg in + switch arg.argument { + case .availabilityLabeledArgument(let labeled): + return platformLimitationLabels.contains(labeled.label.text) + default: + return platformLimitationLabels.contains(arg.trimmedDescription) + } + }) + } + fileprivate var mayHaveGlobalActor: Bool { let wellKnownGlobalActor: Set = [.mainActor] return self.contains { element in @@ -498,6 +530,7 @@ extension VariableDeclSyntax { storageKind: storageKind, canBeInitParam: potentialInitParam, offset: v.offset, + attributes: self.attributes.parsedAttributes.filter(\.isBehavioralAvailable), rxTypes: metadata?.varTypes, customModifiers: metadata?.modifiers, modelDescription: self.description, @@ -549,6 +582,7 @@ extension SubscriptDeclSyntax { isStatic: isStatic, offset: self.offset, length: self.length, + attributes: self.attributes.parsedAttributes.filter(\.isBehavioralAvailable), funcsWithArgsHistory: [], customModifiers: [:], modelDescription: self.description, @@ -580,6 +614,7 @@ extension FunctionDeclSyntax { isStatic: isStatic, offset: self.offset, length: self.length, + attributes: self.attributes.parsedAttributes.filter(\.isBehavioralAvailable), funcsWithArgsHistory: funcsWithArgsHistory ?? [], customModifiers: customModifiers ?? [:], modelDescription: self.description, @@ -624,6 +659,7 @@ extension InitializerDeclSyntax { isStatic: false, offset: self.offset, length: self.length, + attributes: self.attributes.parsedAttributes.filter(\.isBehavioralAvailable), funcsWithArgsHistory: [], customModifiers: [:], modelDescription: self.description, diff --git a/Sources/MockoloFramework/Templates/AttributeTemplate.swift b/Sources/MockoloFramework/Templates/AttributeTemplate.swift new file mode 100644 index 00000000..44505967 --- /dev/null +++ b/Sources/MockoloFramework/Templates/AttributeTemplate.swift @@ -0,0 +1,22 @@ +// +// Copyright (c) 2026. Uber Technologies +// +// 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. +// + +extension [Attribute] { + func applyAttributeTemplate() -> String { + guard !isEmpty else { return "" } + return map { "\(1.tab)\($0.description)" }.joined(separator: "\n") + "\n" + } +} diff --git a/Sources/MockoloFramework/Templates/MethodTemplate.swift b/Sources/MockoloFramework/Templates/MethodTemplate.swift index b1422b59..06b42f5b 100644 --- a/Sources/MockoloFramework/Templates/MethodTemplate.swift +++ b/Sources/MockoloFramework/Templates/MethodTemplate.swift @@ -152,8 +152,9 @@ extension MethodModel { ) let genericWhereStr = model.genericWhereClause.map { "\($0) " } ?? "" + let attrPrefix = model.attributes.applyAttributeTemplate() let functionDecl = """ - \(1.tab)\(declModifiers)\(overrideStr)\(modifierTypeStr)\(keyword)\(model.name)\(genericTypesStr)(\(paramDeclsStr)) \(suffixStr)\(returnClause)\(genericWhereStr){ + \(attrPrefix)\(1.tab)\(declModifiers)\(overrideStr)\(modifierTypeStr)\(keyword)\(model.name)\(genericTypesStr)(\(paramDeclsStr)) \(suffixStr)\(returnClause)\(genericWhereStr){ \(wrapped) \(1.tab)} """ diff --git a/Sources/MockoloFramework/Templates/NominalTemplate.swift b/Sources/MockoloFramework/Templates/NominalTemplate.swift index 5682509f..e2a4f6ec 100644 --- a/Sources/MockoloFramework/Templates/NominalTemplate.swift +++ b/Sources/MockoloFramework/Templates/NominalTemplate.swift @@ -17,14 +17,13 @@ extension NominalModel { func applyNominalTemplate(name: String, accessLevel: String, - attribute: String, arguments: GenerationArguments, initParamCandidates: [VariableModel], declaredInits: [MethodModel], entities: [(String, Model)]) -> String { processCombineAliases(entities: entities) - + let acl = accessLevel.isEmpty ? "" : accessLevel + " " let (aliasItems, @@ -86,6 +85,7 @@ extension NominalModel { uncheckedSendableStr = ", @unchecked Sendable" } + let attribute = attributes.map(\.description).joined(separator: " ") let finalStr = arguments.mockFinal || requiresSendable ? String.final.withSpace : "" let template = """ \(attribute) @@ -206,13 +206,14 @@ extension NominalModel { throwing: m.throwing ) + let attrPrefix = m.attributes.applyAttributeTemplate() if override { let paramsList = m.params.map { param in return "\(param.name): \(param.name.safeName)" }.joined(separator: ", ") return """ - \(1.tab)\(modifier)\(mAcl)init\(genericTypesStr)(\(paramDeclsStr)) \(suffixStr){ + \(attrPrefix)\(1.tab)\(modifier)\(mAcl)init\(genericTypesStr)(\(paramDeclsStr)) \(suffixStr){ \(2.tab)super.init(\(paramsList)) \(1.tab)} """ @@ -227,7 +228,7 @@ extension NominalModel { }.joined(separator: "\n") return """ - \(1.tab)\(modifier)\(mAcl)init\(genericTypesStr)(\(paramDeclsStr)) \(suffixStr){ + \(attrPrefix)\(1.tab)\(modifier)\(mAcl)init\(genericTypesStr)(\(paramDeclsStr)) \(suffixStr){ \(paramsAssign) \(1.tab)} """ diff --git a/Sources/MockoloFramework/Templates/VariableTemplate.swift b/Sources/MockoloFramework/Templates/VariableTemplate.swift index 6917eb59..6dca895b 100644 --- a/Sources/MockoloFramework/Templates/VariableTemplate.swift +++ b/Sources/MockoloFramework/Templates/VariableTemplate.swift @@ -25,6 +25,7 @@ extension VariableModel { accessLevel: String, context: RenderContext, arguments: GenerationArguments) -> String { + let attrPrefix = attributes.applyAttributeTemplate() let underlyingSetCallCount = "\(name)\(String.setCallCountSuffix)" let underlyingVarDefaultVal = type.defaultVal() var underlyingType = type.typeName @@ -85,19 +86,19 @@ extension VariableModel { let template: String if underlyingVarDefaultVal == nil { template = """ - + \(setCallCountVarDecl) \(1.tab)\(propertyWrapper)\(staticSpace)private var \(underlyingName): \(underlyingType)\(assignVal)\(accessorBlock) - \(1.tab)\(acl)\(staticSpace)\(overrideStr)\(modifierTypeStr)var \(name): \(type.typeName) { + \(attrPrefix)\(1.tab)\(acl)\(staticSpace)\(overrideStr)\(modifierTypeStr)var \(name): \(type.typeName) { \(2.tab)get { return \(underlyingName) } \(2.tab)set { \(underlyingName) = newValue } \(1.tab)} """ } else { template = """ - + \(setCallCountVarDecl) - \(1.tab)\(propertyWrapper)\(acl)\(staticSpace)\(overrideStr)\(modifierTypeStr)var \(name): \(type.typeName)\(assignVal)\(accessorBlock) + \(attrPrefix)\(1.tab)\(propertyWrapper)\(acl)\(staticSpace)\(overrideStr)\(modifierTypeStr)var \(name): \(type.typeName)\(assignVal)\(accessorBlock) """ } @@ -120,7 +121,7 @@ extension VariableModel { return """ \(1.tab)\(acl)\(staticSpace)var \(name)\(String.handlerSuffix): (() \(effects.applyTemplate())-> \(type.typeName))? - \(1.tab)\(acl)\(staticSpace)\(overrideStr)\(modifierTypeStr)var \(name): \(type.typeName) { + \(attrPrefix)\(1.tab)\(acl)\(staticSpace)\(overrideStr)\(modifierTypeStr)var \(name): \(type.typeName) { \(2.tab)get \(effects.applyTemplate()){ \(body) \(2.tab)} @@ -135,6 +136,7 @@ extension VariableModel { shouldOverride: Bool, isStatic: Bool, accessLevel: String) -> String? { + let attrPrefix = attributes.applyAttributeTemplate() let typeName = type.typeName guard @@ -190,7 +192,7 @@ extension VariableModel { let setErrorType = ".setFailureType(to: \(errorTypeStr).self)" template += """ - \(1.tab)\(acl)\(staticSpace)\(overrideStr)var \(name): \(typeName) { return \(thisStr).$\(wrapperPropertyName)\(mapping)\(setErrorType).\(String.eraseToAnyPublisher)() } + \(attrPrefix)\(1.tab)\(acl)\(staticSpace)\(overrideStr)var \(name): \(typeName) { return \(thisStr).$\(wrapperPropertyName)\(mapping)\(setErrorType).\(String.eraseToAnyPublisher)() } """ return template default: @@ -211,7 +213,7 @@ extension VariableModel { let template = """ - \(1.tab)\(acl)\(staticSpace)\(overrideStr)var \(name): \(typeName) { return \(thisStr).\(underlyingSubjectName).\(String.eraseToAnyPublisher)() } + \(attrPrefix)\(1.tab)\(acl)\(staticSpace)\(overrideStr)var \(name): \(typeName) { return \(thisStr).\(underlyingSubjectName).\(String.eraseToAnyPublisher)() } \(1.tab)\(acl)\(staticSpace)\(String.privateSet) var \(underlyingSubjectName) = \(combineSubjectType.typeName)<\(typeParamStr)>(\(defaultValue ?? "")) """ return template @@ -226,6 +228,7 @@ extension VariableModel { allowSetCallCount: Bool, isStatic: Bool, accessLevel: String) -> String? { + let attrPrefix = attributes.applyAttributeTemplate() let staticSpace = isStatic ? "\(String.static) " : "" let privateSetSpace = allowSetCallCount ? "" : "\(String.privateSet) " @@ -260,7 +263,7 @@ extension VariableModel { \(1.tab)\(acl)\(staticSpace)\(privateSetSpace)var \(underlyingSetCallCount) = 0 \(1.tab)\(staticSpace)var \(fallbackName): \(fallbackType)? { didSet { \(setCallCountStmt) } } \(1.tab)\(acl)\(staticSpace)var \(underlyingSubjectName)\(defaultValAssignStr) { didSet { \(setCallCountStmt) } } - \(1.tab)\(acl)\(staticSpace)\(overrideStr)var \(name): \(type.typeName) { + \(attrPrefix)\(1.tab)\(acl)\(staticSpace)\(overrideStr)var \(name): \(type.typeName) { \(2.tab)get { return \(fallbackName) ?? \(underlyingSubjectName) } \(2.tab)set { if let val = newValue as? \(underlyingSubjectType) { \(underlyingSubjectName) = val } else { \(fallbackName) = newValue } } \(1.tab)} @@ -298,7 +301,7 @@ extension VariableModel { \(1.tab)\(acl)\(staticSpace)var \(replaySubjectName) = \(replaySubjectType).create(bufferSize: 1) { didSet { \(setCallCountStmt) } } \(1.tab)\(acl)\(staticSpace)var \(behaviorSubjectName): \(behaviorSubjectType)! { didSet { \(setCallCountStmt) } } \(1.tab)\(acl)\(staticSpace)var \(fallbackName): \(fallbackType)! { didSet { \(setCallCountStmt) } } - \(1.tab)\(acl)\(staticSpace)\(overrideStr)var \(name): \(typeName) { + \(attrPrefix)\(1.tab)\(acl)\(staticSpace)\(overrideStr)var \(name): \(typeName) { \(2.tab)get { \(3.tab)if \(whichSubject) == 0 { \(4.tab)return \(publishSubjectName) diff --git a/Sources/MockoloFramework/Utils/InheritanceResolver.swift b/Sources/MockoloFramework/Utils/InheritanceResolver.swift index c115b302..0ab3f5c5 100644 --- a/Sources/MockoloFramework/Utils/InheritanceResolver.swift +++ b/Sources/MockoloFramework/Utils/InheritanceResolver.swift @@ -29,14 +29,14 @@ func lookupEntities(key: String, declKind: NominalTypeDeclKind, protocolMap: [String: Entity], inheritanceMap: [String: Entity], - inheritanceByProtocolMap: [String: Entity]) -> ([Model], [Model], [String], Set, [String]) { + inheritanceByProtocolMap: [String: Entity]) -> ([Model], [Model], [Attribute], Set, [String]) { // Used to keep track of types to be mocked var models = [Model]() // Used to keep track of types that were already mocked var processedModels = [Model]() // Gather attributes declared in current or parent protocols - var attributes = [String]() + var attributes = [Attribute]() // Gather inherited types declared in current or parent protocols var inheritedTypes = Set() // Gather filepaths used for imports diff --git a/Sources/MockoloFramework/Utils/StringExtensions.swift b/Sources/MockoloFramework/Utils/StringExtensions.swift index 5a0025b6..0ff5768b 100644 --- a/Sources/MockoloFramework/Utils/StringExtensions.swift +++ b/Sources/MockoloFramework/Utils/StringExtensions.swift @@ -55,7 +55,6 @@ extension String { static let anyObject = "AnyObject" static let optional = "Optional" static let fatalError = "fatalError" - static let available = "available" static let `public` = "public" static let `open` = "open" static let initializer = "init" diff --git a/Tests/TestAvailable/AvailableTests.swift b/Tests/TestAvailable/AvailableTests.swift new file mode 100644 index 00000000..3cf628a5 --- /dev/null +++ b/Tests/TestAvailable/AvailableTests.swift @@ -0,0 +1,21 @@ +class AvailableTests: MockoloTestCase { + func testDeprecatedMembers() { + verify(srcContent: deprecatedMembers._source, + dstContent: deprecatedMembers.expected._source) + } + + func testProtocolAndMemberAvailable() { + verify(srcContent: protocolAndMemberAvailable._source, + dstContent: protocolAndMemberAvailable.expected._source) + } + + func testMultipleAvailableOnMethod() { + verify(srcContent: multipleAvailableOnMethod._source, + dstContent: multipleAvailableOnMethod.expected._source) + } + + func testMemberPlatformAvailable() { + verify(srcContent: memberPlatformAvailable._source, + dstContent: memberPlatformAvailable.expected._source) + } +} diff --git a/Tests/TestAvailable/FixtureAvailable.swift b/Tests/TestAvailable/FixtureAvailable.swift new file mode 100644 index 00000000..aba2251d --- /dev/null +++ b/Tests/TestAvailable/FixtureAvailable.swift @@ -0,0 +1,180 @@ +import MockoloFramework + +@Fixture enum deprecatedMembers { + /// @mockable + protocol Foo { + @available(*, deprecated, message: "Message for bar") + var bar: String { get set } + + @available(*, deprecated, message: "Message for baz") + func baz() -> String + } + + @Fixture enum expected { + class FooMock: Foo { + init() { } + init(bar: String = "") { + self.bar = bar + } + + + private(set) var barSetCallCount = 0 + @available(*, deprecated, message: "Message for bar") + var bar: String = "" { didSet { barSetCallCount += 1 } } + + private(set) var bazCallCount = 0 + var bazHandler: (() -> String)? + @available(*, deprecated, message: "Message for baz") + func baz() -> String { + bazCallCount += 1 + if let bazHandler = bazHandler { + return bazHandler() + } + return "" + } + } + } +} + +@Fixture enum protocolAndMemberAvailable { + /// @mockable + @available(macOS 13.0, *) + protocol Foo { + func bar() -> String + @available(*, deprecated, message: "Use bar()") + func baz() -> String + } + + @Fixture enum expected { + @available(macOS 13.0, *) + class FooMock: Foo { + init() { } + + + private(set) var barCallCount = 0 + var barHandler: (() -> String)? + func bar() -> String { + barCallCount += 1 + if let barHandler = barHandler { + return barHandler() + } + return "" + } + + private(set) var bazCallCount = 0 + var bazHandler: (() -> String)? + @available(*, deprecated, message: "Use bar()") + func baz() -> String { + bazCallCount += 1 + if let bazHandler = bazHandler { + return bazHandler() + } + return "" + } + } + } +} + +@Fixture enum multipleAvailableOnMethod { + /// @mockable + protocol Foo { + @available(*, noasync) + @available(*, deprecated, message: "Use async version") + func bar() -> String + + @available(*, noasync) + @discardableResult + func string(for key: String) -> Result + } + + @Fixture enum expected { + class FooMock: Foo { + init() { } + + + private(set) var barCallCount = 0 + var barHandler: (() -> String)? + @available(*, noasync) + @available(*, deprecated, message: "Use async version") + func bar() -> String { + barCallCount += 1 + if let barHandler = barHandler { + return barHandler() + } + return "" + } + + private(set) var stringCallCount = 0 + var stringHandler: ((String) -> Result)? + @available(*, noasync) + func string(for key: String) -> Result { + stringCallCount += 1 + if let stringHandler = stringHandler { + return stringHandler(key) + } + fatalError("stringHandler returns can't have a default value thus its handler must be set") + } + } + } +} + +@Fixture enum memberPlatformAvailable { + @available(macOS 99.0, *) + struct AAA {} + + @available(macOS 80.0, *) + struct BBB {} + + @available(iOS 99.0, *) + struct CCC {} + + /// @mockable + protocol Foo { + @available(macOS 99.0, *) + func aaa() -> AAA + + @available(macOS 80.0, *) + func bbb() -> BBB + + @available(iOS 99.0, *) + var ccc: CCC { get } + } + + @Fixture enum expected { + @available(macOS 99.0, *) @available(macOS 80.0, *) @available(iOS 99.0, *) + class FooMock: Foo { + init() { } + init(ccc: CCC) { + self._ccc = ccc + } + + + private(set) var aaaCallCount = 0 + var aaaHandler: (() -> AAA)? + func aaa() -> AAA { + aaaCallCount += 1 + if let aaaHandler = aaaHandler { + return aaaHandler() + } + fatalError("aaaHandler returns can't have a default value thus its handler must be set") + } + + private(set) var bbbCallCount = 0 + var bbbHandler: (() -> BBB)? + func bbb() -> BBB { + bbbCallCount += 1 + if let bbbHandler = bbbHandler { + return bbbHandler() + } + fatalError("bbbHandler returns can't have a default value thus its handler must be set") + } + + + private var _ccc: CCC! + var ccc: CCC { + get { return _ccc } + set { _ccc = newValue } + } + } + } +} diff --git a/Tests/TestAvailable/ParseAttributeTests.swift b/Tests/TestAvailable/ParseAttributeTests.swift new file mode 100644 index 00000000..528769e4 --- /dev/null +++ b/Tests/TestAvailable/ParseAttributeTests.swift @@ -0,0 +1,49 @@ +#if canImport(Testing) +import Testing +import SwiftSyntax +import SwiftSyntaxBuilder +@testable import MockoloFramework + +@Suite struct ParseAttributeTests { + @Test(arguments: [ + ("@available(macOS 10.15, *)", true), + ("@available(iOS, introduced: 13.0)", true), + ("@available(iOS, unavailable)", true), + ("@available(iOS, deprecated: 14.0)", false), + ("@available(iOS, obsoleted: 15.0)", true), + ("@available(*, deprecated)", false), + ("@available(*, noasync)", false), + ("@available(*, message: \"foo\")", false), + ]) + func isPlatformAvailability(input: String, expectedIsPlatform: Bool) throws { + let decl = try ProtocolDeclSyntax("\(raw: input) protocol Foo {}") + let parsed = decl.attributes.parsedAttributes + + let attribute = try #require(parsed.first) + #expect(attribute.isPlatformAvailable == expectedIsPlatform) + } + + @Test func multipleAttributes() throws { + let decl = try ProtocolDeclSyntax(""" + @available(iOS 13.0, *) @available(*, deprecated) + protocol Foo {} + """) + let parsed = decl.attributes.parsedAttributes + + try #require(parsed.count == 2) + #expect(parsed[0].isPlatformAvailable) + #expect(parsed[1].isBehavioralAvailable) + } + + @Test func mixedArguments() throws { + let decl = try ProtocolDeclSyntax(""" + @available(iOS, introduced: 13.0, deprecated: 14.0, message: \"Use something else\") + protocol Foo {} + """) + let parsed = decl.attributes.parsedAttributes + + let attribute = try #require(parsed.first) + #expect(attribute.isPlatformAvailable) + } +} +#endif