From 39e406f720e4026db24c3d79f5ef26a510104929 Mon Sep 17 00:00:00 2001 From: Iceman Date: Wed, 30 Oct 2024 09:58:07 +0900 Subject: [PATCH] Add Nested protocol support (#270) * Scan nested protocol and add test * remove unused `data` property * Avoid mock generation in generic context * Render mocks in extension when the mock has namespaces * A bit performance improve --- .../Models/NominalModel.swift | 3 + .../Models/ParsedEntity.swift | 9 +- .../Parsers/SwiftSyntaxExtensions.swift | 75 +++++++++++----- .../Templates/NominalTemplate.swift | 12 ++- .../Templates/VariableTemplate.swift | 4 +- .../Utils/InheritanceResolver.swift | 10 +-- .../Utils/StringExtensions.swift | 11 +++ Tests/TestModuleNames/ModuleNameTests.swift | 9 -- .../FixtureModuleOverrides.swift | 0 .../FixtureNestedProtocol.swift | 90 +++++++++++++++++++ Tests/TestNamespaces/NamespacesTests.swift | 16 ++++ 11 files changed, 191 insertions(+), 48 deletions(-) delete mode 100644 Tests/TestModuleNames/ModuleNameTests.swift rename Tests/{TestModuleNames => TestNamespaces}/FixtureModuleOverrides.swift (100%) create mode 100644 Tests/TestNamespaces/FixtureNestedProtocol.swift create mode 100644 Tests/TestNamespaces/NamespacesTests.swift diff --git a/Sources/MockoloFramework/Models/NominalModel.swift b/Sources/MockoloFramework/Models/NominalModel.swift index 14126a02..a4f63781 100644 --- a/Sources/MockoloFramework/Models/NominalModel.swift +++ b/Sources/MockoloFramework/Models/NominalModel.swift @@ -22,6 +22,7 @@ final class NominalModel: Model { case `actor` } + let namespaces: [String] var name: String var offset: Int64 var type: SwiftType @@ -41,6 +42,7 @@ final class NominalModel: Model { } init(identifier: String, + namespaces: [String], acl: String, declTypeOfMockAnnotatedBaseType: DeclType, declKind: NominalTypeDeclKind, @@ -54,6 +56,7 @@ final class NominalModel: Model { self.identifier = identifier self.name = metadata?.nameOverride ?? (identifier + "Mock") self.type = SwiftType(self.name) + self.namespaces = namespaces self.declTypeOfMockAnnotatedBaseType = declTypeOfMockAnnotatedBaseType self.declKind = declKind self.inheritedTypes = inheritedTypes diff --git a/Sources/MockoloFramework/Models/ParsedEntity.swift b/Sources/MockoloFramework/Models/ParsedEntity.swift index e85c6a73..ba34e746 100644 --- a/Sources/MockoloFramework/Models/ParsedEntity.swift +++ b/Sources/MockoloFramework/Models/ParsedEntity.swift @@ -64,6 +64,7 @@ struct ResolvedEntity { func model() -> Model { return NominalModel(identifier: key, + namespaces: entity.entityNode.namespaces, acl: entity.entityNode.accessLevel, declTypeOfMockAnnotatedBaseType: entity.entityNode.declType, declKind: inheritsActorProtocol ? .actor : .class, @@ -84,6 +85,7 @@ struct ResolvedEntityContainer { } protocol EntityNode { + var namespaces: [String] { get } var nameText: String { get } var accessLevel: String { get } var attributesDescription: String { get } @@ -91,7 +93,7 @@ protocol EntityNode { var inheritedTypes: [String] { get } var offset: Int64 { get } var hasBlankInit: Bool { get } - func subContainer(metadata: AnnotationMetadata?, declType: DeclType, path: String?, data: Data?, isProcessed: Bool) -> EntityNodeSubContainer + func subContainer(metadata: AnnotationMetadata?, declType: DeclType, path: String?, isProcessed: Bool) -> EntityNodeSubContainer } final class EntityNodeSubContainer { @@ -139,7 +141,6 @@ public typealias ImportMap = [String: [String: [String]]] /// Metadata for a type being mocked public final class Entity { var filepath: String = "" - var data: Data? = nil let entityNode: EntityNode let isProcessed: Bool let metadata: AnnotationMetadata? @@ -150,7 +151,6 @@ public final class Entity { static func node(with entityNode: EntityNode, filepath: String, - data: Data? = nil, isPrivate: Bool, isFinal: Bool, metadata: AnnotationMetadata?, @@ -160,7 +160,6 @@ public final class Entity { let node = Entity(entityNode: entityNode, filepath: filepath, - data: data, metadata: metadata, isProcessed: processed) @@ -169,12 +168,10 @@ public final class Entity { init(entityNode: EntityNode, filepath: String = "", - data: Data? = nil, metadata: AnnotationMetadata?, isProcessed: Bool) { self.entityNode = entityNode self.filepath = filepath - self.data = data self.metadata = metadata self.isProcessed = isProcessed } diff --git a/Sources/MockoloFramework/Parsers/SwiftSyntaxExtensions.swift b/Sources/MockoloFramework/Parsers/SwiftSyntaxExtensions.swift index c4a2ac81..dacf2eb4 100644 --- a/Sources/MockoloFramework/Parsers/SwiftSyntaxExtensions.swift +++ b/Sources/MockoloFramework/Parsers/SwiftSyntaxExtensions.swift @@ -259,6 +259,10 @@ extension IfConfigDeclSyntax { } extension ProtocolDeclSyntax: EntityNode { + var namespaces: [String] { + return findNamespaces(parent: parent) + } + var nameText: String { return name.text } @@ -295,12 +299,15 @@ extension ProtocolDeclSyntax: EntityNode { return false } - func subContainer(metadata: AnnotationMetadata?, declType: DeclType, path: String?, data: Data?, isProcessed: Bool) -> EntityNodeSubContainer { + func subContainer(metadata: AnnotationMetadata?, declType: DeclType, path: String?, isProcessed: Bool) -> EntityNodeSubContainer { return self.memberBlock.members.memberData(with: accessLevel, declType: declType, metadata: metadata, processed: isProcessed) } } extension ClassDeclSyntax: EntityNode { + var namespaces: [String] { + return findNamespaces(parent: parent) + } var nameText: String { return name.text @@ -346,11 +353,34 @@ extension ClassDeclSyntax: EntityNode { return leadingTrivia.annotationMetadata(with: annotation) } - func subContainer(metadata: AnnotationMetadata?, declType: DeclType, path: String?, data: Data?, isProcessed: Bool) -> EntityNodeSubContainer { + func subContainer(metadata: AnnotationMetadata?, declType: DeclType, path: String?, isProcessed: Bool) -> EntityNodeSubContainer { return self.memberBlock.members.memberData(with: accessLevel, declType: declType, metadata: nil, processed: isProcessed) } } +fileprivate func findNamespaces(parent: Syntax?) -> [String] { + guard let parent else { + return [] + } + return sequence(first: parent, next: \.parent) + .compactMap { element in + if let decl = element.as(StructDeclSyntax.self) { + return decl.name.trimmedDescription + } else if let decl = element.as(EnumDeclSyntax.self) { + return decl.name.trimmedDescription + } else if let decl = element.as(ClassDeclSyntax.self) { + return decl.name.trimmedDescription + } else if let decl = element.as(ActorDeclSyntax.self) { + return decl.name.trimmedDescription + } else if let decl = element.as(ExtensionDeclSyntax.self) { + return decl.extendedType.trimmedDescription + } else { + return nil + } + } + .reversed() +} + extension VariableDeclSyntax { func models(with acl: String, declType: DeclType, metadata: AnnotationMetadata?, processed: Bool) -> [Model] { // Detect whether it's static @@ -618,9 +648,7 @@ final class EntityVisitor: SyntaxVisitor { super.init(viewMode: .sourceAccurate) } - override func visit(_ node: ProtocolDeclSyntax) -> SyntaxVisitorContinueKind { visitImpl(node) } - - private func visitImpl(_ node: ProtocolDeclSyntax) -> SyntaxVisitorContinueKind { + override func visit(_ node: ProtocolDeclSyntax) -> SyntaxVisitorContinueKind { let metadata = node.annotationMetadata(with: annotation) if let ent = Entity.node(with: node, filepath: path, isPrivate: node.isPrivate, isFinal: false, metadata: metadata, processed: false) { entities.append(ent) @@ -628,9 +656,15 @@ final class EntityVisitor: SyntaxVisitor { return .skipChildren } - override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind { visitImpl(node) } + override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind { + return node.genericParameterClause != nil ? .skipChildren : .visitChildren + } - private func visitImpl(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind { + override func visit(_ node: EnumDeclSyntax) -> SyntaxVisitorContinueKind { + return node.genericParameterClause != nil ? .skipChildren : .visitChildren + } + + override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind { if node.nameText.hasSuffix("Mock") { // this mock class node must be public else wouldn't have compiled before if let ent = Entity.node(with: node, filepath: path, isPrivate: node.isPrivate, isFinal: false, metadata: nil, processed: true) { @@ -644,25 +678,22 @@ final class EntityVisitor: SyntaxVisitor { } } } - return .skipChildren + return node.genericParameterClause != nil ? .skipChildren : .visitChildren } - override func visit(_ node: ImportDeclSyntax) -> SyntaxVisitorContinueKind { visitImpl(node) } + override func visit(_ node: ActorDeclSyntax) -> SyntaxVisitorContinueKind { + return node.genericParameterClause != nil ? .skipChildren : .visitChildren + } - private func visitImpl(_ node: ImportDeclSyntax) -> SyntaxVisitorContinueKind { + override func visit(_ node: ImportDeclSyntax) -> SyntaxVisitorContinueKind { if let ret = node.path.firstToken(viewMode: .sourceAccurate)?.text { let desc = node.importKeyword.text + " " + ret - if imports[""] == nil { - imports[""] = [] - } - imports[""]?.append(desc) + imports["", default: []].append(desc) } - return .visitChildren + return .skipChildren } - override func visit(_ node: IfConfigDeclSyntax) -> SyntaxVisitorContinueKind { visitImpl(node) } - - private func visitImpl(_ node: IfConfigDeclSyntax) -> SyntaxVisitorContinueKind { + override func visit(_ node: IfConfigDeclSyntax) -> SyntaxVisitorContinueKind { for cl in node.clauses { let macroName: String if let conditionDescription = cl.condition?.trimmedDescription { @@ -697,11 +728,15 @@ final class EntityVisitor: SyntaxVisitor { return .skipChildren } - override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind { + override func visit(_ node: InitializerDeclSyntax) -> SyntaxVisitorContinueKind { + return .skipChildren + } + + override func visit(_ node: FunctionDeclSyntax) -> SyntaxVisitorContinueKind { return .skipChildren } - override func visit(_ node: EnumDeclSyntax) -> SyntaxVisitorContinueKind { + override func visit(_ node: VariableDeclSyntax) -> SyntaxVisitorContinueKind { return .skipChildren } } diff --git a/Sources/MockoloFramework/Templates/NominalTemplate.swift b/Sources/MockoloFramework/Templates/NominalTemplate.swift index 63d84e9a..df4ba05f 100644 --- a/Sources/MockoloFramework/Templates/NominalTemplate.swift +++ b/Sources/MockoloFramework/Templates/NominalTemplate.swift @@ -101,8 +101,16 @@ extension NominalModel { \(body) } """ - - return template + + if namespaces.isEmpty { + return template + } else { + return """ + extension \(namespaces.joined(separator: ".")) { + \(template.addingIndent(1)) + } + """ + } } private func extraInitsIfNeeded( diff --git a/Sources/MockoloFramework/Templates/VariableTemplate.swift b/Sources/MockoloFramework/Templates/VariableTemplate.swift index 0aaa9f97..3a444666 100644 --- a/Sources/MockoloFramework/Templates/VariableTemplate.swift +++ b/Sources/MockoloFramework/Templates/VariableTemplate.swift @@ -116,9 +116,7 @@ extension VariableModel { returnType: type, encloser: "" ).render(with: name, encloser: "") ?? "") - .split(separator: "\n") - .map { "\(1.tab)\($0)" } - .joined(separator: "\n") + .addingIndent(1) return """ diff --git a/Sources/MockoloFramework/Utils/InheritanceResolver.swift b/Sources/MockoloFramework/Utils/InheritanceResolver.swift index 360087c8..368ad580 100644 --- a/Sources/MockoloFramework/Utils/InheritanceResolver.swift +++ b/Sources/MockoloFramework/Utils/InheritanceResolver.swift @@ -44,15 +44,12 @@ func lookupEntities(key: String, // Look up the mock entities of a protocol specified by the name. if let current = protocolMap[key] { - let sub = current.entityNode.subContainer(metadata: current.metadata, declType: declType, path: current.filepath, data: current.data, isProcessed: current.isProcessed) + let sub = current.entityNode.subContainer(metadata: current.metadata, declType: declType, path: current.filepath, isProcessed: current.isProcessed) models.append(contentsOf: sub.members) if !current.isProcessed { attributes.append(contentsOf: sub.attributes) } inheritedTypes.formUnion(current.entityNode.inheritedTypes) - if let data = current.data { - pathToContents.append((current.filepath, data, current.entityNode.offset)) - } paths.append(current.filepath) @@ -72,14 +69,11 @@ func lookupEntities(key: String, } } else if let parentMock = inheritanceMap["\(key)Mock"], declType == .protocolType { // If the parent protocol is not in the protocol map, look it up in the input parent mocks map. - let sub = parentMock.entityNode.subContainer(metadata: parentMock.metadata, declType: declType, path: parentMock.filepath, data: parentMock.data, isProcessed: parentMock.isProcessed) + let sub = parentMock.entityNode.subContainer(metadata: parentMock.metadata, declType: declType, path: parentMock.filepath, isProcessed: parentMock.isProcessed) processedModels.append(contentsOf: sub.members) if !parentMock.isProcessed { attributes.append(contentsOf: sub.attributes) } - if let data = parentMock.data { - pathToContents.append((parentMock.filepath, data, parentMock.entityNode.offset)) - } paths.append(parentMock.filepath) } diff --git a/Sources/MockoloFramework/Utils/StringExtensions.swift b/Sources/MockoloFramework/Utils/StringExtensions.swift index 0bf2598e..31c2cd65 100644 --- a/Sources/MockoloFramework/Utils/StringExtensions.swift +++ b/Sources/MockoloFramework/Utils/StringExtensions.swift @@ -190,6 +190,17 @@ extension String { guard self.hasPrefix(prefix) else { return self } return String(self.dropFirst(prefix.count)) } + + func addingIndent(_ tabs: Int) -> String { + self.split(separator: "\n") + .map { line in + if line.isEmpty { + return "" + } + return "\(tabs.tab)\(line)" + } + .joined(separator: "\n") + } } let separatorsForDisplay = CharacterSet(charactersIn: "<>[] :,()_-.&@#!{}@+\"\'") diff --git a/Tests/TestModuleNames/ModuleNameTests.swift b/Tests/TestModuleNames/ModuleNameTests.swift deleted file mode 100644 index 0fa9f832..00000000 --- a/Tests/TestModuleNames/ModuleNameTests.swift +++ /dev/null @@ -1,9 +0,0 @@ -import Foundation - -class ModuleNameTests: MockoloTestCase { - - func testModuleOverride() { - verify(srcContent: moduleOverride, - dstContent: moduleOverrideMock) - } -} diff --git a/Tests/TestModuleNames/FixtureModuleOverrides.swift b/Tests/TestNamespaces/FixtureModuleOverrides.swift similarity index 100% rename from Tests/TestModuleNames/FixtureModuleOverrides.swift rename to Tests/TestNamespaces/FixtureModuleOverrides.swift diff --git a/Tests/TestNamespaces/FixtureNestedProtocol.swift b/Tests/TestNamespaces/FixtureNestedProtocol.swift new file mode 100644 index 00000000..daaf1adc --- /dev/null +++ b/Tests/TestNamespaces/FixtureNestedProtocol.swift @@ -0,0 +1,90 @@ +import MockoloFramework + +let nestedProtocol = """ +@MainActor final class FooView: UIView { + /// \(String.mockAnnotation) + @MainActor protocol Delegate: AnyObject { + func onTapButton(_ button: UIButton) + } + + weak var delegate: Delegate? +} + +extension AAA { + actor BBB { + /// \(String.mockAnnotation) + protocol CCC { + } + } +} +""" + +let nestedProtocolMock = """ +extension FooView { + + class DelegateMock: Delegate { + init() { } + + + private(set) var onTapButtonCallCount = 0 + var onTapButtonHandler: ((UIButton) -> ())? + func onTapButton(_ button: UIButton) { + onTapButtonCallCount += 1 + if let onTapButtonHandler = onTapButtonHandler { + onTapButtonHandler(button) + } + + } + } +} +extension AAA.BBB { + + class CCCMock: CCC { + init() { } + + + } +} +""" + +let nestedProtocolInGeneric = """ +actor Foo { + /// \(String.mockAnnotation) + protocol NG1 { + func requirement() -> Int + } +} + +enum Bar { + struct Baz { + /// \(String.mockAnnotation) + protocol NG2 { + func requirement() -> Int + } + } +} + +/// \(String.mockAnnotation) +protocol OK { + associatedtype T + func requirement() -> T +} +""" + +let nestedProtocolInGenericMock = """ +class OKMock: OK { + init() { } + + typealias T = Any + + private(set) var requirementCallCount = 0 + var requirementHandler: (() -> (T))? + func requirement() -> T { + requirementCallCount += 1 + if let requirementHandler = requirementHandler { + return requirementHandler() + } + fatalError("requirementHandler returns can't have a default value thus its handler must be set") + } +} +""" diff --git a/Tests/TestNamespaces/NamespacesTests.swift b/Tests/TestNamespaces/NamespacesTests.swift new file mode 100644 index 00000000..669ee108 --- /dev/null +++ b/Tests/TestNamespaces/NamespacesTests.swift @@ -0,0 +1,16 @@ +class NamespacesTests: MockoloTestCase { + func testModuleOverride() { + verify(srcContent: moduleOverride, + dstContent: moduleOverrideMock) + } + + func testNestedProtocol() { + verify(srcContent: nestedProtocol, + dstContent: nestedProtocolMock) + } + + func testNestedProtocolInGeneric() { + verify(srcContent: nestedProtocolInGeneric, + dstContent: nestedProtocolInGenericMock) + } +}