diff --git a/Sources/MockoloFramework/Models/ArgumentsHistoryModel.swift b/Sources/MockoloFramework/Models/ArgumentsHistoryModel.swift index 0e8bc2a2..63e7611e 100644 --- a/Sources/MockoloFramework/Models/ArgumentsHistoryModel.swift +++ b/Sources/MockoloFramework/Models/ArgumentsHistoryModel.swift @@ -1,11 +1,24 @@ -import Foundation +// +// Copyright (c) 2018. 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. +// final class ArgumentsHistoryModel: Model { let name: String - let type: SwiftType + let capturedValueType: SwiftType let offset: Int64 = .max - let capturableParamNames: [String] - let capturableParamTypes: [SwiftType] + let capturableParams: [(String, SwiftType)] let isHistoryAnnotated: Bool var modelType: ModelType { @@ -22,11 +35,10 @@ final class ArgumentsHistoryModel: Model { self.name = name + .argsHistorySuffix self.isHistoryAnnotated = isHistoryAnnotated - self.capturableParamNames = capturables.map(\.name.safeName) - self.capturableParamTypes = capturables.map(\.type) + self.capturableParams = capturables.map { ($0.name.safeName, $0.type) } let genericTypeNameList = genericTypeParams.map(\.name) - self.type = SwiftType.toArgumentsHistoryType(with: capturableParamTypes, typeParams: genericTypeNameList) + self.capturedValueType = SwiftType.toArgumentsCaptureType(with: capturableParams.map(\.1), typeParams: genericTypeNameList) } func enable(force: Bool) -> Bool { @@ -44,11 +56,11 @@ final class ArgumentsHistoryModel: Model { return nil } - switch capturableParamNames.count { + switch capturableParams.count { case 1: - return "\(overloadingResolvedName)\(String.argsHistorySuffix).append(\(capturableParamNames[0]))" + return "\(overloadingResolvedName)\(String.argsHistorySuffix).append(\(capturableParams[0].0))" case 2...: - let paramNamesStr = capturableParamNames.joined(separator: ", ") + let paramNamesStr = capturableParams.map(\.0).joined(separator: ", ") return "\(overloadingResolvedName)\(String.argsHistorySuffix).append((\(paramNamesStr)))" default: fatalError("paramNames must not be empty.") diff --git a/Sources/MockoloFramework/Models/ClosureModel.swift b/Sources/MockoloFramework/Models/ClosureModel.swift index 8204ab39..0375a8ad 100644 --- a/Sources/MockoloFramework/Models/ClosureModel.swift +++ b/Sources/MockoloFramework/Models/ClosureModel.swift @@ -22,8 +22,7 @@ final class ClosureModel: Model { let funcReturnType: SwiftType let genericTypeNames: [String] - let paramNames: [String] - let paramTypes: [SwiftType] + let params: [(String, SwiftType)] let isAsync: Bool let throwing: ThrowingKind @@ -31,40 +30,39 @@ final class ClosureModel: Model { return .closure } - init(genericTypeParams: [ParamModel], paramNames: [String], paramTypes: [SwiftType], isAsync: Bool, throwing: ThrowingKind, returnType: SwiftType) { + init(genericTypeParams: [ParamModel], params: [(String, SwiftType)], isAsync: Bool, throwing: ThrowingKind, returnType: SwiftType) { // In the mock's call handler, rethrows is unavailable. let throwing = throwing.coerceRethrowsToThrows self.isAsync = isAsync self.throwing = throwing self.genericTypeNames = genericTypeParams.map(\.name) - self.paramNames = paramNames - self.paramTypes = paramTypes + self.params = params self.funcReturnType = returnType } - func type(enclosingType: SwiftType) -> SwiftType { + func type(enclosingType: SwiftType, requiresSendable: Bool) -> SwiftType { return SwiftType.toClosureType( - params: paramTypes, + params: params.map(\.1), typeParams: genericTypeNames, isAsync: isAsync, throwing: throwing, returnType: funcReturnType, - encloser: enclosingType + encloser: enclosingType, + requiresSendable: requiresSendable ) } func render( context: RenderContext, - arguments: GenerationArguments = .default + arguments: GenerationArguments ) -> String? { guard let overloadingResolvedName = context.overloadingResolvedName, let enclosingType = context.enclosingType else { return nil } - return applyClosureTemplate(type: type(enclosingType: enclosingType), + return applyClosureTemplate(type: type(enclosingType: enclosingType, requiresSendable: context.requiresSendable), name: overloadingResolvedName + .handlerSuffix, - paramVals: paramNames, - paramTypes: paramTypes, + params: params, returnDefaultType: funcReturnType) } } diff --git a/Sources/MockoloFramework/Models/MethodModel.swift b/Sources/MockoloFramework/Models/MethodModel.swift index 895f411c..0cf3bc85 100644 --- a/Sources/MockoloFramework/Models/MethodModel.swift +++ b/Sources/MockoloFramework/Models/MethodModel.swift @@ -142,8 +142,7 @@ final class MethodModel: Model { } return ClosureModel(genericTypeParams: genericTypeParams, - paramNames: params.map(\.name), - paramTypes: params.map(\.type), + params: params.map { ($0.name, $0.type) }, isAsync: isAsync, throwing: throwing, returnType: returnType) diff --git a/Sources/MockoloFramework/Models/Model.swift b/Sources/MockoloFramework/Models/Model.swift index c5d094bd..40a23cb8 100644 --- a/Sources/MockoloFramework/Models/Model.swift +++ b/Sources/MockoloFramework/Models/Model.swift @@ -37,6 +37,7 @@ struct RenderContext { var overloadingResolvedName: String? var enclosingType: SwiftType? var annotatedTypeKind: NominalTypeDeclKind? + var requiresSendable: Bool = false } /// Represents a model for an entity such as var, func, class, etc. diff --git a/Sources/MockoloFramework/Models/NominalModel.swift b/Sources/MockoloFramework/Models/NominalModel.swift index b041aaa6..3cc76c3b 100644 --- a/Sources/MockoloFramework/Models/NominalModel.swift +++ b/Sources/MockoloFramework/Models/NominalModel.swift @@ -23,12 +23,12 @@ final class NominalModel: Model { let accessLevel: String let identifier: String let declKindOfMockAnnotatedBaseType: NominalTypeDeclKind - let inheritedTypes: [String] let entities: [(String, Model)] let initParamCandidates: [VariableModel] let declaredInits: [MethodModel] let metadata: AnnotationMetadata? let declKind: NominalTypeDeclKind + let requiresSendable: Bool var modelType: ModelType { return .nominal @@ -39,20 +39,19 @@ final class NominalModel: Model { acl: String, declKindOfMockAnnotatedBaseType: NominalTypeDeclKind, declKind: NominalTypeDeclKind, - inheritedTypes: [String], attributes: [String], offset: Int64, metadata: AnnotationMetadata?, initParamCandidates: [VariableModel], declaredInits: [MethodModel], - entities: [(String, Model)]) { - self.identifier = identifier + entities: [(String, Model)], + requiresSendable: Bool) { + self.identifier = identifier self.name = metadata?.nameOverride ?? (identifier + "Mock") self.type = SwiftType(self.name) self.namespaces = namespaces self.declKindOfMockAnnotatedBaseType = declKindOfMockAnnotatedBaseType self.declKind = declKind - self.inheritedTypes = inheritedTypes self.entities = entities self.declaredInits = declaredInits self.initParamCandidates = initParamCandidates @@ -60,6 +59,7 @@ final class NominalModel: Model { self.offset = offset self.attribute = Set(attributes.filter {$0.contains(String.available)}).joined(separator: " ") self.accessLevel = acl + self.requiresSendable = requiresSendable } func render( @@ -71,7 +71,6 @@ final class NominalModel: Model { identifier: self.identifier, accessLevel: accessLevel, attribute: attribute, - inheritedTypes: inheritedTypes, metadata: metadata, arguments: arguments, initParamCandidates: initParamCandidates, diff --git a/Sources/MockoloFramework/Models/ParamModel.swift b/Sources/MockoloFramework/Models/ParamModel.swift index bb2f5ba1..1b04ef69 100644 --- a/Sources/MockoloFramework/Models/ParamModel.swift +++ b/Sources/MockoloFramework/Models/ParamModel.swift @@ -14,8 +14,6 @@ // limitations under the License. // -import Foundation - final class ParamModel: Model { internal init(label: String, name: String, type: SwiftType, isGeneric: Bool, inInit: Bool, needsVarDecl: Bool, offset: Int64, length: Int64) { self.label = label @@ -84,9 +82,23 @@ final class ParamModel: Model { } func render( - context: RenderContext = .init(), - arguments: GenerationArguments = .default + context: RenderContext, + arguments: GenerationArguments ) -> String? { return applyParamTemplate(name: name, label: label, type: type, inInit: inInit) } } + +extension [ParamModel] { + func render( + context: RenderContext, + arguments: GenerationArguments + ) -> String { + return self.compactMap { + $0.render( + context: context, + arguments: arguments + ) + }.joined(separator: ", ") + } +} diff --git a/Sources/MockoloFramework/Models/ParsedEntity.swift b/Sources/MockoloFramework/Models/ParsedEntity.swift index e75e08db..cee410e2 100644 --- a/Sources/MockoloFramework/Models/ParsedEntity.swift +++ b/Sources/MockoloFramework/Models/ParsedEntity.swift @@ -23,7 +23,6 @@ struct ResolvedEntity { var uniqueModels: [(String, Model)] var attributes: [String] var inheritedTypes: [String] - var inheritsActorProtocol: Bool var declaredInits: [MethodModel] { return uniqueModels.compactMap { (_, model) in @@ -39,6 +38,10 @@ struct ResolvedEntity { ) } + var inheritsActorProtocol: Bool { + return inheritedTypes.contains(.actorProtocol) + } + /// Returns models that can be used as parameters to an initializer /// @param models The models of the current entity including unprocessed (ones to generate) and /// processed (already mocked by a previous run if any) models. @@ -56,19 +59,23 @@ struct ResolvedEntity { return result } + var requiresSendable: Bool { + return inheritedTypes.contains(.sendable) || inheritedTypes.contains(.error) + } + func model() -> Model { return NominalModel(identifier: key, namespaces: entity.entityNode.namespaces, acl: entity.entityNode.accessLevel, declKindOfMockAnnotatedBaseType: entity.entityNode.declKind, declKind: inheritsActorProtocol ? .actor : .class, - inheritedTypes: inheritedTypes, attributes: attributes, offset: entity.entityNode.offset, metadata: entity.metadata, initParamCandidates: initParamCandidates, declaredInits: declaredInits, - entities: uniqueModels) + entities: uniqueModels, + requiresSendable: requiresSendable) } } diff --git a/Sources/MockoloFramework/Models/TypeAliasModel.swift b/Sources/MockoloFramework/Models/TypeAliasModel.swift index 13ca0acc..146dd445 100644 --- a/Sources/MockoloFramework/Models/TypeAliasModel.swift +++ b/Sources/MockoloFramework/Models/TypeAliasModel.swift @@ -58,7 +58,7 @@ final class TypeAliasModel: Model { func render( context: RenderContext, - arguments: GenerationArguments = .default + arguments: GenerationArguments ) -> String? { let addAcl = context.annotatedTypeKind == .protocol && !processed if processed || useDescription, let modelDescription = modelDescription?.trimmingCharacters(in: .whitespacesAndNewlines) { diff --git a/Sources/MockoloFramework/Models/VariableModel.swift b/Sources/MockoloFramework/Models/VariableModel.swift index 47c80e2b..c49362c1 100644 --- a/Sources/MockoloFramework/Models/VariableModel.swift +++ b/Sources/MockoloFramework/Models/VariableModel.swift @@ -127,6 +127,7 @@ final class VariableModel: Model { allowSetCallCount: arguments.allowSetCallCount, shouldOverride: shouldOverride, accessLevel: accessLevel, - context: context) + context: context, + arguments: arguments) } } diff --git a/Sources/MockoloFramework/Operations/Generator.swift b/Sources/MockoloFramework/Operations/Generator.swift index fb7d35d0..02af6d45 100644 --- a/Sources/MockoloFramework/Operations/Generator.swift +++ b/Sources/MockoloFramework/Operations/Generator.swift @@ -157,17 +157,25 @@ public func generate(sourceDirs: [String], signpost_begin(name: "Write results") log("Write the mock results and import lines to", outputFilePath, level: .info) + let needsConcurrencyHelpers = resolvedEntities.contains { $0.requiresSendable } + let imports = handleImports(pathToImportsMap: pathToImportsMap, - customImports: customImports, + customImports: customImports + (needsConcurrencyHelpers ? ["Foundation"] : []), excludeImports: excludeImports, testableImports: testableImports, relevantPaths: relevantPaths) + var helpers = [String]() + if needsConcurrencyHelpers { + helpers.append(applyConcurrencyHelpersTemplate()) + } + let result = try write(candidates: candidates, - header: header, - macro: macro, - imports: imports, - to: outputFilePath) + header: header, + macro: macro, + imports: imports, + helpers: helpers, + to: outputFilePath) signpost_end(name: "Write results") let t5 = CFAbsoluteTimeGetCurrent() log("Took", t5-t4, level: .verbose) diff --git a/Sources/MockoloFramework/Operations/OutputWriter.swift b/Sources/MockoloFramework/Operations/OutputWriter.swift index be21cedf..4df29c94 100644 --- a/Sources/MockoloFramework/Operations/OutputWriter.swift +++ b/Sources/MockoloFramework/Operations/OutputWriter.swift @@ -21,6 +21,7 @@ func write(candidates: [(String, Int64)], header: String?, macro: String?, imports: String, + helpers: [String], to outputFilePath: String) throws -> String { let entities = candidates @@ -35,11 +36,18 @@ func write(candidates: [(String, Int64)], let headerStr = (header ?? "") + .headerDoc var macroStart = "" var macroEnd = "" - if let mcr = macro, !mcr.isEmpty { - macroStart = .poundIf + mcr + if let macro, !macro.isEmpty { + macroStart = .poundIf + macro macroEnd = .poundEndIf } - let ret = [headerStr, macroStart, imports, entities.joined(separator: "\n"), macroEnd].joined(separator: "\n\n") + let ret = [ + headerStr, + macroStart, + imports, + entities.joined(separator: "\n"), + helpers.joined(separator: "\n\n"), + macroEnd, + ].joined(separator: "\n\n") let currentFileContents = try? String(contentsOfFile: outputFilePath, encoding: .utf8) guard currentFileContents != ret else { log("Not writing the file as content is unchanged", level: .info) diff --git a/Sources/MockoloFramework/Operations/UniqueModelGenerator.swift b/Sources/MockoloFramework/Operations/UniqueModelGenerator.swift index 7e29e485..d4e6f0ed 100644 --- a/Sources/MockoloFramework/Operations/UniqueModelGenerator.swift +++ b/Sources/MockoloFramework/Operations/UniqueModelGenerator.swift @@ -68,18 +68,12 @@ private func generateUniqueModels(key: String, let uniqueModels = [mockedUniqueEntities, unmockedUniqueEntities].flatMap {$0} .sorted(path: \.value.offset, fallback: \.key) - var mockInheritedTypes = [String]() - if inheritedTypes.contains(.sendable) { - mockInheritedTypes.append(.uncheckedSendable) - } - let resolvedEntity = ResolvedEntity( key: key, entity: entity, uniqueModels: uniqueModels, attributes: attributes, - inheritedTypes: mockInheritedTypes, - inheritsActorProtocol: inheritedTypes.contains(.actorProtocol) + inheritedTypes: inheritedTypes.sorted() ) return ResolvedEntityContainer(entity: resolvedEntity, paths: paths) diff --git a/Sources/MockoloFramework/Templates/ClosureTemplate.swift b/Sources/MockoloFramework/Templates/ClosureTemplate.swift index 019fc861..849c7c57 100644 --- a/Sources/MockoloFramework/Templates/ClosureTemplate.swift +++ b/Sources/MockoloFramework/Templates/ClosureTemplate.swift @@ -19,28 +19,21 @@ import Foundation extension ClosureModel { func applyClosureTemplate(type: SwiftType, name: String, - paramVals: [String]?, - paramTypes: [SwiftType]?, + params: [(String, SwiftType)], returnDefaultType: SwiftType) -> String { - - var handlerParamValsStr = "" - if let paramVals = paramVals, let paramTypes = paramTypes { - let zipped = zip(paramVals, paramTypes).map { (arg) -> String in - let (argName, argType) = arg - if argType.isAutoclosure { - return argName.safeName + "()" - } - if argType.isInOut { - return "&" + argName.safeName - } - if argType.hasClosure && argType.isOptional, - let renderedClosure = renderOptionalGenericClosure(argType: argType, argName: argName) { - return renderedClosure - } - return argName.safeName + let handlerParamValsStr = params.map { (argName, argType) -> String in + if argType.isAutoclosure { + return argName.safeName + "()" } - handlerParamValsStr = zipped.joined(separator: ", ") - } + if argType.isInOut { + return "&" + argName.safeName + } + if argType.hasClosure && argType.isOptional, + let renderedClosure = renderOptionalGenericClosure(argType: argType, argName: argName) { + return renderedClosure + } + return argName.safeName + }.joined(separator: ", ") let handlerReturnDefault = renderReturnDefaultStatement(name: name, type: returnDefaultType) let prefix = [ @@ -48,17 +41,14 @@ extension ClosureModel { isAsync ? String.await + " " : nil, ].compactMap { $0 }.joined() - let returnStr = returnDefaultType.typeName.isEmpty ? "" : "return " - let callExpr = "\(returnStr)\(prefix)\(name)(\(handlerParamValsStr))\(type.cast ?? "")" - - let template = """ + let returnStr = returnDefaultType.isVoid ? "" : "return " + + return """ \(2.tab)if let \(name) = \(name) { - \(3.tab)\(callExpr) + \(3.tab)\(returnStr)\(prefix)\(name)(\(handlerParamValsStr))\(type.cast ?? "") \(2.tab)} \(2.tab)\(handlerReturnDefault) """ - - return template } diff --git a/Sources/MockoloFramework/Templates/ConcurrencyHelpersTemplate.swift b/Sources/MockoloFramework/Templates/ConcurrencyHelpersTemplate.swift new file mode 100644 index 00000000..4b65858a --- /dev/null +++ b/Sources/MockoloFramework/Templates/ConcurrencyHelpersTemplate.swift @@ -0,0 +1,45 @@ +func applyConcurrencyHelpersTemplate() -> String { + return #""" +fileprivate func warnIfNotSendable(function: String = #function, _: repeat each T) { + print("At \(function), the captured arguments are not Sendable, it is not concurrency-safe.") +} + +fileprivate func warnIfNotSendable(function: String = #function, _: repeat each T) { +} + +/// Will be replaced to `Synchronization.Mutex` in future. +fileprivate final class MockoloMutex: @unchecked Sendable { + private let lock = NSLock() + private var value: Value + init(_ initialValue: Value) { + self.value = initialValue + } +#if compiler(>=6.0) + borrowing func withLock(_ body: (inout sending Value) throws(E) -> Result) throws(E) -> sending Result { + lock.lock() + defer { lock.unlock() } + return try body(&value) + } +#else + func withLock(_ body: (inout Value) throws -> Result) rethrows -> Result { + lock.lock() + defer { lock.unlock() } + return try body(&value) + } +#endif +} + +fileprivate struct MockoloUnsafeTransfer: @unchecked Sendable { + var value: Value + init(_ value: repeat each T) where Value == (repeat each T) { + self.value = (repeat each value) + } +} + +fileprivate struct MockoloHandlerState { + var argValues: [MockoloUnsafeTransfer] = [] + var handler: Handler? = nil + var callCount: Int = 0 +} +"""# +} diff --git a/Sources/MockoloFramework/Templates/MethodTemplate.swift b/Sources/MockoloFramework/Templates/MethodTemplate.swift index 0413d58e..8e3799a7 100644 --- a/Sources/MockoloFramework/Templates/MethodTemplate.swift +++ b/Sources/MockoloFramework/Templates/MethodTemplate.swift @@ -22,123 +22,226 @@ extension MethodModel { isOverride: Bool, handler: ClosureModel?, context: RenderContext) -> String { - var template = "" - - let returnTypeName = returnType.isUnknown ? "" : returnType.typeName - - let acl = accessLevel.isEmpty ? "" : accessLevel+" " - let genericTypeDeclsStr = genericTypeParams.compactMap {$0.render()}.joined(separator: ", ") - let genericTypesStr = genericTypeDeclsStr.isEmpty ? "" : "<\(genericTypeDeclsStr)>" - let genericWhereStr = genericWhereClause.map { "\($0) " } ?? "" - let paramDeclsStr = params.compactMap{$0.render()}.joined(separator: ", ") - - switch kind { - case .initKind(_, _): // ClassTemplate needs to handle this as it needs a context of all the vars - return "" - default: - - guard let handler, let enclosingType = context.enclosingType else { return "" } + if case .initKind = kind { + return "" // ClassTemplate needs to handle this as it needs a context of all the vars + } - let callCount = "\(overloadingResolvedName)\(String.callCountSuffix)" - let handlerVarName = "\(overloadingResolvedName)\(String.handlerSuffix)" - let handlerVarType = handler.type(enclosingType: enclosingType).typeName // ?? "Any" - let handlerReturn = handler.render(context: context) ?? "" + guard let handler, let enclosingType = context.enclosingType else { return "" } + + return Renderer( + model: self, + context: context, + arguments: arguments, + overloadingResolvedName: overloadingResolvedName, + isOverride: isOverride, + handler: handler, + enclosingType: enclosingType + ) + .render() + } - let suffixStr = applyFunctionSuffixTemplate( - isAsync: isAsync, - throwing: throwing - ) - let returnStr = returnTypeName.isEmpty ? "" : "-> \(returnTypeName) " - let staticStr = isStatic ? String.static + " " : "" - let keyword = isSubscript ? "" : "func " - var body = "" - - if arguments.useTemplateFunc { - let callMockFunc = !throwing.hasError && (handler.type(enclosingType: enclosingType).cast?.isEmpty ?? false) - if callMockFunc { - let handlerParamValsStr = params.map { (arg) -> String in - if arg.type.typeName.hasPrefix(String.autoclosure) { - return arg.name.safeName + "()" - } - return arg.name.safeName - }.joined(separator: ", ") - - let defaultVal = returnType.defaultVal() // ?? "nil" - - var mockReturn = ".error" - if returnType.typeName.isEmpty { - mockReturn = ".void" - } else if let val = defaultVal { - mockReturn = ".val(\(val))" + struct Renderer { + var model: MethodModel + var context: RenderContext + var arguments: GenerationArguments + var overloadingResolvedName: String + var isOverride: Bool + var handler: ClosureModel + var enclosingType: SwiftType + + func render() -> String { + let body: String + if arguments.useTemplateFunc + && !model.throwing.hasError + && (handler.type(enclosingType: enclosingType, requiresSendable: context.requiresSendable).cast?.isEmpty ?? false) { + let handlerParamValsStr = model.params.map { (arg) -> String in + if arg.type.typeName.hasPrefix(String.autoclosure) { + return arg.name.safeName + "()" } + return arg.name.safeName + }.joined(separator: ", ") - body = """ - \(2.tab)mockFunc(&\(callCount))(\"\(name)\", \(handlerVarName)?(\(handlerParamValsStr)), \(mockReturn)) - """ + let defaultVal = model.returnType.defaultVal() // ?? "nil" + + var mockReturn = ".error" + if model.returnType.typeName.isEmpty { + mockReturn = ".void" + } else if let val = defaultVal { + mockReturn = ".val(\(val))" } - } - if body.isEmpty { body = """ - \(2.tab)\(callCount) += 1 + \(2.tab)mockFunc(&\(callCountVarName))(\"\(model.name)\", \(handlerVarName)?(\(handlerParamValsStr)), \(mockReturn)) """ + } else { + let handlerReturn = handler.render(context: context, arguments: arguments) + + if context.requiresSendable { + let paramNamesStr: String? + if let argsHistory = model.argsHistory, argsHistory.enable(force: arguments.enableFuncArgsHistory) { + paramNamesStr = argsHistory.capturableParams.map(\.0).joined(separator: ", ") + } else { + paramNamesStr = nil + } + body = [ + paramNamesStr.map { "\(2.tab)warnIfNotSendable(\($0))" }, + "\(2.tab)let \(handlerVarName) = \(stateVarName).withLock { state in", + "\(3.tab)state.callCount += 1", + paramNamesStr.map { "\(3.tab)state.argValues.append(.init(\($0)))" }, + "\(3.tab)return state.handler", + "\(2.tab)}", + handlerReturn, + ].compactMap { $0 }.joined(separator: "\n") + } else { + let argsHistoryCaptureCall: String? + if let argsHistory = model.argsHistory, argsHistory.enable(force: arguments.enableFuncArgsHistory) { + let argsHistoryCapture = argsHistory.render(context: context, arguments: arguments) ?? "" + argsHistoryCaptureCall = argsHistoryCapture + } else { + argsHistoryCaptureCall = nil + } - if let argsHistory, argsHistory.enable(force: arguments.enableFuncArgsHistory) { - let argsHistoryCapture = argsHistory.render(context: context, arguments: arguments) ?? "" - - body = """ - \(body) - \(2.tab)\(argsHistoryCapture) - """ + body = [ + "\(2.tab)\(callCountVarName) += 1", + argsHistoryCaptureCall.map { "\(2.tab)\($0)" }, + handlerReturn, + ].compactMap { $0 }.joined(separator: "\n") } - - body = """ - \(body) - \(handlerReturn) - """ } - var wrapped = body - if isSubscript { - wrapped = """ - \(2.tab)get { - \(body) - \(2.tab)} - \(2.tab)set { } - """ - } + let wrapped = model.isSubscript ? """ + \(2.tab)get { + \(body) + \(2.tab)} + \(2.tab)set { } + """ : body - let overrideStr = isOverride ? "\(String.override) " : "" + let overrideStr = isOverride ? String.override.withSpace : "" let modifierTypeStr: String - if let customModifier: Modifier = customModifiers[name] { + if let customModifier: Modifier = model.customModifiers[model.name] { modifierTypeStr = customModifier.rawValue + " " } else { modifierTypeStr = "" } - let privateSetSpace = arguments.allowSetCallCount ? "" : "\(String.privateSet) " - template = """ + let keyword = model.isSubscript ? "" : "func " + let genericTypeDeclsStr = model.genericTypeParams.render(context: context, arguments: arguments) + let genericTypesStr = genericTypeDeclsStr.isEmpty ? "" : "<\(genericTypeDeclsStr)>" + let paramDeclsStr = model.params.render(context: context, arguments: arguments) + let suffixStr = applyFunctionSuffixTemplate( + isAsync: model.isAsync, + throwing: model.throwing + ) + let genericWhereStr = model.genericWhereClause.map { "\($0) " } ?? "" - \(1.tab)\(acl)\(staticStr)\(privateSetSpace)var \(callCount) = 0 + let functionDecl = """ + \(1.tab)\(declModifiers)\(overrideStr)\(modifierTypeStr)\(keyword)\(model.name)\(genericTypesStr)(\(paramDeclsStr)) \(suffixStr)\(returnClause)\(genericWhereStr){ + \(wrapped) + \(1.tab)} """ - if let argsHistory = argsHistory, argsHistory.enable(force: arguments.enableFuncArgsHistory) { - let argsHistoryVarName = "\(overloadingResolvedName)\(String.argsHistorySuffix)" - let argsHistoryVarType = argsHistory.type.typeName + let decls: [String?] = [ + stateVarDecl, + callCountVarDecl, + argsHistoryVarDecl, + handlerVarDecl, + functionDecl, + ] + return "\n" + decls.compactMap { $0 }.joined(separator: "\n") + } - template = """ - \(template) - \(1.tab)\(acl)\(staticStr)var \(argsHistoryVarName) = \(argsHistoryVarType)() - """ + var declModifiers: String { + let acl = model.accessLevel.isEmpty ? "" : model.accessLevel.withSpace + let staticModifier = model.isStatic ? String.static.withSpace : "" + return acl + staticModifier + } + + var returnClause: String { + let returnTypeName = model.returnType.isUnknown ? "" : model.returnType.typeName + return returnTypeName.isEmpty ? "" : "-> \(returnTypeName) " + } + + var handlerVarName: String { + return overloadingResolvedName + .handlerSuffix + } + + var stateVarName: String { + return overloadingResolvedName + .stateSuffix + } + + var callCountVarName: String { + return overloadingResolvedName + .callCountSuffix + } + + var argsHistoryVarName: String { + return overloadingResolvedName + .argsHistorySuffix + } + + var stateVarDecl: String? { + guard context.requiresSendable else { return nil } + + let handlerType = handler.type(enclosingType: enclosingType, requiresSendable: context.requiresSendable).typeName + let argumentsTupleType: String + if let argsHistory = model.argsHistory, argsHistory.enable(force: arguments.enableFuncArgsHistory) { + argumentsTupleType = argsHistory.capturedValueType.typeName + } else { + argumentsTupleType = .neverType } + return "\(1.tab)private let \(stateVarName) = MockoloMutex(MockoloHandlerState<\(argumentsTupleType), \(handlerType)>())" + } - return """ - \(template) - \(1.tab)\(acl)\(staticStr)var \(handlerVarName): \(handlerVarType) - \(1.tab)\(acl)\(staticStr)\(overrideStr)\(modifierTypeStr)\(keyword)\(name)\(genericTypesStr)(\(paramDeclsStr)) \(suffixStr)\(returnStr)\(genericWhereStr){ - \(wrapped) - \(1.tab)} - """ + var callCountVarDecl: String { + if !context.requiresSendable { + let privateSetSpace = arguments.allowSetCallCount ? "" : "\(String.privateSet) " + return "\(1.tab)\(declModifiers)\(privateSetSpace)var \(callCountVarName) = 0" + } else { + if arguments.allowSetCallCount { + return """ + \(1.tab)\(declModifiers)var \(callCountVarName): Int { + \(2.tab)get { \(stateVarName).withLock(\\.callCount) } + \(2.tab)set { \(stateVarName).withLock { $0.callCount = newValue } } + \(1.tab)} + """ + } else { + return """ + \(1.tab)\(declModifiers)var \(callCountVarName): Int { + \(2.tab)return \(stateVarName).withLock(\\.callCount) + \(1.tab)} + """ + } + } + } + + var argsHistoryVarDecl: String? { + if let argsHistory = model.argsHistory, argsHistory.enable(force: arguments.enableFuncArgsHistory) { + let capturedValueType = argsHistory.capturedValueType.typeName + + if !context.requiresSendable { + return "\(1.tab)\(declModifiers)var \(argsHistoryVarName) = [\(capturedValueType)]()" + } else { + return """ + \(1.tab)\(declModifiers)var \(argsHistoryVarName): [\(capturedValueType)] { + \(2.tab)return \(stateVarName).withLock(\\.argValues).map(\\.value) + \(1.tab)} + """ + } + } + return nil + } + + var handlerVarDecl: String { + let handlerType = handler.type(enclosingType: enclosingType, requiresSendable: context.requiresSendable).typeName // ?? "Any" + let handlerVarType = "(\(handlerType))?" + if !context.requiresSendable { + return "\(1.tab)\(declModifiers)var \(handlerVarName): \(handlerVarType)" + } else { + return """ + \(1.tab)\(declModifiers)var \(handlerVarName): \(handlerVarType) { + \(2.tab)get { \(stateVarName).withLock(\\.handler) } + \(2.tab)set { \(stateVarName).withLock { $0.handler = newValue } } + \(1.tab)} + """ + } } } } diff --git a/Sources/MockoloFramework/Templates/NominalTemplate.swift b/Sources/MockoloFramework/Templates/NominalTemplate.swift index d0eff8c8..2f7fff5c 100644 --- a/Sources/MockoloFramework/Templates/NominalTemplate.swift +++ b/Sources/MockoloFramework/Templates/NominalTemplate.swift @@ -21,7 +21,6 @@ extension NominalModel { identifier: String, accessLevel: String, attribute: String, - inheritedTypes: [String], metadata: AnnotationMetadata?, arguments: GenerationArguments, initParamCandidates: [VariableModel], @@ -48,7 +47,8 @@ extension NominalModel { context: .init( overloadingResolvedName: uniqueId, enclosingType: type, - annotatedTypeKind: declKindOfMockAnnotatedBaseType + annotatedTypeKind: declKindOfMockAnnotatedBaseType, + requiresSendable: requiresSendable ), arguments: arguments ) { @@ -72,10 +72,19 @@ extension NominalModel { moduleDot = moduleName + "." } - let extraInits = extraInitsIfNeeded(initParamCandidates: initParamCandidates, declaredInits: declaredInits, acl: acl, declKindOfMockAnnotatedBaseType: declKindOfMockAnnotatedBaseType, overrides: metadata?.varTypes) - - var inheritedTypes = inheritedTypes - inheritedTypes.insert("\(moduleDot)\(identifier)", at: 0) + let extraInits = extraInitsIfNeeded( + initParamCandidates: initParamCandidates, + declaredInits: declaredInits, + acl: acl, + declKindOfMockAnnotatedBaseType: declKindOfMockAnnotatedBaseType, + overrides: metadata?.varTypes, + context: .init( + enclosingType: type, + annotatedTypeKind: declKindOfMockAnnotatedBaseType, + requiresSendable: requiresSendable + ), + arguments: arguments + ) var body = "" if !typealiasTemplate.isEmpty { @@ -87,11 +96,15 @@ extension NominalModel { if !renderedEntities.isEmpty { body += "\(renderedEntities)" } + var uncheckedSendableStr = "" + if requiresSendable { + uncheckedSendableStr = ", @unchecked Sendable" + } - let finalStr = arguments.mockFinal ? "\(String.final) " : "" + let finalStr = arguments.mockFinal || requiresSendable ? String.final.withSpace : "" let template = """ \(attribute) - \(acl)\(finalStr)\(declKind.rawValue) \(name): \(inheritedTypes.joined(separator: ", ")) { + \(acl)\(finalStr)\(declKind.rawValue) \(name): \(moduleDot)\(identifier)\(uncheckedSendableStr) { \(body) } """ @@ -112,7 +125,9 @@ extension NominalModel { declaredInits: [MethodModel], acl: String, declKindOfMockAnnotatedBaseType: NominalTypeDeclKind, - overrides: [String: String]? + overrides: [String: String]?, + context: RenderContext, + arguments: GenerationArguments ) -> String { let declaredInitParamsPerInit = declaredInits.map { $0.params } @@ -197,9 +212,9 @@ extension NominalModel { if case let .initKind(required, override) = m.kind, !m.processed { let modifier = required ? "\(String.required) " : (override ? "\(String.override) " : "") let mAcl = m.accessLevel.isEmpty ? "" : "\(m.accessLevel) " - let genericTypeDeclsStr = m.genericTypeParams.compactMap {$0.render()}.joined(separator: ", ") + let genericTypeDeclsStr = m.genericTypeParams.render(context: context, arguments: arguments) let genericTypesStr = genericTypeDeclsStr.isEmpty ? "" : "<\(genericTypeDeclsStr)>" - let paramDeclsStr = m.params.compactMap{$0.render()}.joined(separator: ", ") + let paramDeclsStr = m.params.render(context: context, arguments: arguments) let suffixStr = applyFunctionSuffixTemplate( isAsync: m.isAsync, throwing: m.throwing diff --git a/Sources/MockoloFramework/Templates/VariableTemplate.swift b/Sources/MockoloFramework/Templates/VariableTemplate.swift index fce353d3..e02a463a 100644 --- a/Sources/MockoloFramework/Templates/VariableTemplate.swift +++ b/Sources/MockoloFramework/Templates/VariableTemplate.swift @@ -17,7 +17,6 @@ import Foundation extension VariableModel { - func applyVariableTemplate(name: String, type: SwiftType, encloser: String, @@ -26,8 +25,8 @@ extension VariableModel { allowSetCallCount: Bool, shouldOverride: Bool, accessLevel: String, - context: RenderContext) -> String { - + context: RenderContext, + arguments: GenerationArguments) -> String { let underlyingSetCallCount = "\(name)\(String.setCallCountSuffix)" let underlyingVarDefaultVal = type.defaultVal() var underlyingType = type.typeName @@ -109,8 +108,7 @@ extension VariableModel { case .computed(let effects): let body = (ClosureModel( genericTypeParams: [], - paramNames: [], - paramTypes: [], + params: [], isAsync: effects.isAsync, throwing: effects.throwing, returnType: type @@ -118,7 +116,7 @@ extension VariableModel { overloadingResolvedName: name, // var cannot overload. this is ok enclosingType: context.enclosingType, annotatedTypeKind: context.annotatedTypeKind - )) ?? "") + ), arguments: arguments) ?? "") .addingIndent(1) return """ @@ -358,14 +356,7 @@ extension VariableModel { extension VariableModel.GetterEffects { fileprivate func applyTemplate() -> String { - var clauses: [String] = [] - if isAsync { - clauses.append(.async) - } - if let throwSyntax = throwing.applyThrowingTemplate() { - clauses.append(throwSyntax) - } - return clauses.map { "\($0) " }.joined() + return applyFunctionSuffixTemplate(isAsync: isAsync, throwing: throwing) } fileprivate var callerMarkers: String { diff --git a/Sources/MockoloFramework/Utils/StringExtensions.swift b/Sources/MockoloFramework/Utils/StringExtensions.swift index 378ae29f..bb29b65c 100644 --- a/Sources/MockoloFramework/Utils/StringExtensions.swift +++ b/Sources/MockoloFramework/Utils/StringExtensions.swift @@ -94,12 +94,13 @@ extension String { static let underlyingVarPrefix = "_" static let setCallCountSuffix = "SetCallCount" static let callCountSuffix = "CallCount" + static let stateSuffix = "State" static let initializerLeftParen = "init(" static let `escaping` = "@escaping" static let autoclosure = "@autoclosure" static let name = "name" static let sendable = "Sendable" - static let uncheckedSendable = "@unchecked Sendable" + static let error = "Error" static let mainActor = "MainActor" static public let mockAnnotation = "@mockable" static public let mockObservable = "@MockObservable" diff --git a/Sources/MockoloFramework/Utils/TypeParser.swift b/Sources/MockoloFramework/Utils/TypeParser.swift index 9baa7888..199a0d31 100644 --- a/Sources/MockoloFramework/Utils/TypeParser.swift +++ b/Sources/MockoloFramework/Utils/TypeParser.swift @@ -200,6 +200,10 @@ public final class SwiftType { return true } + var isVoid: Bool { + return typeName.isEmpty || typeName == "()" || typeName == "Void" + } + var hasValidBrackets: Bool { let arg = typeName if let _ = arg.rangeOfCharacter(from: CharacterSet(arrayLiteral: "<", "["), options: [], range: nil) { @@ -484,7 +488,8 @@ public final class SwiftType { isAsync: Bool, throwing: ThrowingKind, returnType: SwiftType, - encloser: SwiftType + encloser: SwiftType, + requiresSendable: Bool ) -> SwiftType { let displayableParamTypes = params.map { (subtype: SwiftType) -> String in return subtype.processTypeParams(with: typeParams) @@ -532,11 +537,12 @@ public final class SwiftType { throwing: throwing ) - let typeStr = "((\(displayableParamStr)) \(suffixStr)-> \(displayableReturnType))?" + let sendableStr = requiresSendable ? "@Sendable " : "" + let typeStr = "\(sendableStr)(\(displayableParamStr)) \(suffixStr)-> \(displayableReturnType)" return SwiftType(typeStr, cast: returnTypeCast) } - static func toArgumentsHistoryType(with params: [SwiftType], typeParams: [String]) -> SwiftType { + static func toArgumentsCaptureType(with params: [SwiftType], typeParams: [String]) -> SwiftType { // Expected only history capturable types. let displayableParamTypes = params.compactMap { (subtype: SwiftType) -> String? in var processedType = subtype.processTypeParams(with: typeParams) @@ -554,9 +560,9 @@ public final class SwiftType { let displayableParamStr = displayableParamTypes.joined(separator: ", ") if displayableParamTypes.count >= 2 { - return SwiftType("[(\(displayableParamStr))]") + return SwiftType("(\(displayableParamStr))") } else { - return SwiftType("[\(displayableParamStr)]") + return SwiftType(displayableParamStr) } } diff --git a/Tests/MockoloTestCase.swift b/Tests/MockoloTestCase.swift index 4eafbad8..85842ba8 100644 --- a/Tests/MockoloTestCase.swift +++ b/Tests/MockoloTestCase.swift @@ -99,11 +99,8 @@ class MockoloTestCase: XCTestCase { let macroEnd = String.poundEndIf let headerStr = header + String.headerDoc - index = 0 - if let mockContents = mockContents { - - for mockContent in mockContents { - + if let mockContents { + for (index, mockContent) in mockContents.enumerated() { let formattedMockContent = """ \(headerStr) \(macroStart) @@ -111,18 +108,10 @@ class MockoloTestCase: XCTestCase { \(macroEnd) """ let mockCreated = FileManager.default.createFile(atPath: mockFilePaths[index], contents: formattedMockContent.data(using: .utf8), attributes: nil) - index += 1 XCTAssert(mockCreated) } } - let formattedDstContent = """ - \(headerStr) - \(macroStart) - \(dstContent) - \(macroEnd) - """ - try generate(sourceDirs: [], sourceFiles: srcFilePaths, parser: SourceParser(), @@ -147,9 +136,25 @@ class MockoloTestCase: XCTestCase { onCompletion: { ret in let output = (try? String(contentsOf: URL(fileURLWithPath: self.defaultDstFilePath), encoding: .utf8)) ?? "" let outputContents = output.components(separatedBy: .whitespacesAndNewlines).filter{!$0.isEmpty} - let fixtureContents = formattedDstContent.components(separatedBy: .whitespacesAndNewlines).filter{!$0.isEmpty} - XCTAssert(fixtureContents == outputContents, "output:\n" + output) + let fixtureContents = dstContent.components(separatedBy: .whitespacesAndNewlines).filter{!$0.isEmpty} + XCTAssert(outputContents.contains(subArray: fixtureContents), "output:\n" + output) }) } } +extension Array where Element: Equatable { + fileprivate func contains(subArray: [Element]) -> Bool { + guard subArray.count <= self.count else { + return false + } + + for i in 0...(self.count - subArray.count) { + let slice = self[i.. String + func update(arg0: some Sendable, arg1: AnyObject) async throws } """ -let sendableProtocolMock = """ - - - -public class SendableProtocolMock: SendableProtocol, @unchecked Sendable { +let sendableProtocolMock = #""" +public final class SendableProtocolMock: SendableProtocol, @unchecked Sendable { public init() { } - public private(set) var updateCallCount = 0 - public var updateHandler: ((Int) -> String)? + private let updateState = MockoloMutex(MockoloHandlerState String>()) + public var updateCallCount: Int { + return updateState.withLock(\.callCount) + } + public var updateArgValues: [Int] { + return updateState.withLock(\.argValues).map(\.value) + } + public var updateHandler: (@Sendable (Int) -> String)? { + get { updateState.withLock(\.handler) } + set { updateState.withLock { $0.handler = newValue } } + } public func update(arg: Int) -> String { - updateCallCount += 1 + warnIfNotSendable(arg) + let updateHandler = updateState.withLock { state in + state.callCount += 1 + state.argValues.append(.init(arg)) + return state.handler + } if let updateHandler = updateHandler { return updateHandler(arg) } return "" } + + private let updateArg0State = MockoloMutex(MockoloHandlerState<(Any, AnyObject), @Sendable (Any, AnyObject) async throws -> ()>()) + public var updateArg0CallCount: Int { + return updateArg0State.withLock(\.callCount) + } + public var updateArg0ArgValues: [(Any, AnyObject)] { + return updateArg0State.withLock(\.argValues).map(\.value) + } + public var updateArg0Handler: (@Sendable (Any, AnyObject) async throws -> ())? { + get { updateArg0State.withLock(\.handler) } + set { updateArg0State.withLock { $0.handler = newValue } } + } + public func update(arg0: some Sendable, arg1: AnyObject) async throws { + warnIfNotSendable(arg0, arg1) + let updateArg0Handler = updateArg0State.withLock { state in + state.callCount += 1 + state.argValues.append(.init(arg0, arg1)) + return state.handler + } + if let updateArg0Handler = updateArg0Handler { + try await updateArg0Handler(arg0, arg1) + } + + } } +"""# -""" let uncheckedSendableClass = """ /// \(String.mockAnnotation) @@ -35,26 +71,31 @@ public class UncheckedSendableClass: @unchecked Sendable { } """ -let uncheckedSendableClassMock = """ - - - -public class UncheckedSendableClassMock: UncheckedSendableClass, @unchecked Sendable { +let uncheckedSendableClassMock = #""" +public final class UncheckedSendableClassMock: UncheckedSendableClass, @unchecked Sendable { public init() { } - private(set) var updateCallCount = 0 - var updateHandler: ((Int) -> String)? + private let updateState = MockoloMutex(MockoloHandlerState String>()) + var updateCallCount: Int { + return updateState.withLock(\.callCount) + } + var updateHandler: (@Sendable (Int) -> String)? { + get { updateState.withLock(\.handler) } + set { updateState.withLock { $0.handler = newValue } } + } override func update(arg: Int) -> String { - updateCallCount += 1 + let updateHandler = updateState.withLock { state in + state.callCount += 1 + return state.handler + } if let updateHandler = updateHandler { return updateHandler(arg) } return "" } } - -""" +"""# let confirmedSendableProtocol = """ public protocol SendableSendable: Sendable { @@ -66,19 +107,84 @@ public protocol ConfirmedSendableProtocol: SendableSendable { } """ -let confirmedSendableProtocolMock = """ -public class ConfirmedSendableProtocolMock: ConfirmedSendableProtocol, @unchecked Sendable { +let confirmedSendableProtocolMock = #""" +public final class ConfirmedSendableProtocolMock: ConfirmedSendableProtocol, @unchecked Sendable { public init() { } - public private(set) var updateCallCount = 0 - public var updateHandler: ((Int) -> String)? + private let updateState = MockoloMutex(MockoloHandlerState String>()) + public var updateCallCount: Int { + return updateState.withLock(\.callCount) + } + public var updateHandler: (@Sendable (Int) -> String)? { + get { updateState.withLock(\.handler) } + set { updateState.withLock { $0.handler = newValue } } + } public func update(arg: Int) -> String { - updateCallCount += 1 + let updateHandler = updateState.withLock { state in + state.callCount += 1 + return state.handler + } if let updateHandler = updateHandler { return updateHandler(arg) } return "" } } +"""# + +let generatedConcurrencyHelpers = """ +/// \(String.mockAnnotation) +public protocol SendableProtocol: Sendable { +} """ + +let generatedConcurrencyHelpersMock = #""" +import Foundation + +public final class SendableProtocolMock: SendableProtocol, @unchecked Sendable { + public init() { } +} + +fileprivate func warnIfNotSendable(function: String = #function, _: repeat each T) { + print("At \(function), the captured arguments are not Sendable, it is not concurrency-safe.") +} + +fileprivate func warnIfNotSendable(function: String = #function, _: repeat each T) { +} + +/// Will be replaced to `Synchronization.Mutex` in future. +fileprivate final class MockoloMutex: @unchecked Sendable { + private let lock = NSLock() + private var value: Value + init(_ initialValue: Value) { + self.value = initialValue + } +#if compiler(>=6.0) + borrowing func withLock(_ body: (inout sending Value) throws(E) -> Result) throws(E) -> sending Result { + lock.lock() + defer { lock.unlock() } + return try body(&value) + } +#else + func withLock(_ body: (inout Value) throws -> Result) rethrows -> Result { + lock.lock() + defer { lock.unlock() } + return try body(&value) + } +#endif +} + +fileprivate struct MockoloUnsafeTransfer: @unchecked Sendable { + var value: Value + init(_ value: repeat each T) where Value == (repeat each T) { + self.value = (repeat each value) + } +} + +fileprivate struct MockoloHandlerState { + var argValues: [MockoloUnsafeTransfer] = [] + var handler: Handler? = nil + var callCount: Int = 0 +} +"""# diff --git a/Tests/TestSendable/SendableTests.swift b/Tests/TestSendable/SendableTests.swift index d4741259..aca859b7 100644 --- a/Tests/TestSendable/SendableTests.swift +++ b/Tests/TestSendable/SendableTests.swift @@ -1,7 +1,8 @@ class SendableTests: MockoloTestCase { func testSendableProtocol() { verify(srcContent: sendableProtocol, - dstContent: sendableProtocolMock) + dstContent: sendableProtocolMock, + enableFuncArgsHistory: true) } func testUncheckedSendableClass() { @@ -14,4 +15,9 @@ class SendableTests: MockoloTestCase { verify(srcContent: confirmedSendableProtocol, dstContent: confirmedSendableProtocolMock) } + + func testGenerateConcurrencyHelpers() { + verify(srcContent: generatedConcurrencyHelpers, + dstContent: generatedConcurrencyHelpersMock) + } }