diff --git a/Sources/MockoloFramework/Models/ParsedEntity.swift b/Sources/MockoloFramework/Models/ParsedEntity.swift index 58d516b7..bde7bc92 100644 --- a/Sources/MockoloFramework/Models/ParsedEntity.swift +++ b/Sources/MockoloFramework/Models/ParsedEntity.swift @@ -152,12 +152,20 @@ struct GenerationArguments { typealias ImportMap = [String: [ImportContent]] +/// Tracks which #if clause an entity was found in at file scope +struct IfConfigContext { + let blockOffset: Int64 + let clauseType: IfClauseType + let clauseIndex: Int +} + /// Metadata for a type being mocked public final class Entity { let entityNode: EntityNode let filepath: String let metadata: AnnotationMetadata? let isProcessed: Bool + var ifConfigContext: IfConfigContext? var isAnnotated: Bool { return metadata != nil diff --git a/Sources/MockoloFramework/Operations/TemplateRenderer.swift b/Sources/MockoloFramework/Operations/TemplateRenderer.swift index 12379c14..498997d0 100644 --- a/Sources/MockoloFramework/Operations/TemplateRenderer.swift +++ b/Sources/MockoloFramework/Operations/TemplateRenderer.swift @@ -14,20 +14,72 @@ // limitations under the License. // +import Foundation + /// Renders models with templates for output func renderTemplates(entities: [ResolvedEntity], arguments: GenerationArguments, completion: @escaping (String, Int64) -> ()) { - scan(entities) { (resolvedEntity, lock) in + // Separate standalone entities from #if-grouped entities + var standalone = [ResolvedEntity]() + var ifConfigBlockOffsets = Set() + var ifConfigGroups = [Int64: [Int: (IfClauseType, [ResolvedEntity])]]() + + for entity in entities { + if let context = entity.entity.ifConfigContext { + ifConfigGroups[context.blockOffset, default: [:]][context.clauseIndex, default: (context.clauseType, [])].1.append(entity) + ifConfigBlockOffsets.insert(context.blockOffset) + } else { + standalone.append(entity) + } + } + + // Lock used for thread-safe completion callbacks + let lock = NSLock() + + // Render standalone entities + scan(standalone) { (resolvedEntity, _) in let mockModel = resolvedEntity.model() if let mockString = mockModel.render( context: .init(), arguments: arguments ), !mockString.isEmpty { - lock?.lock() + lock.lock() completion(mockString, mockModel.offset) - lock?.unlock() + lock.unlock() + } + } + + // Render #if-grouped entities, preserving #if/#elseif/#else/#endif structure. + // Note: Only the immediate #if context is preserved. Deeply nested #if blocks + // (e.g., `#if A #if B protocol P #endif #endif`) will only wrap mocks in the + // innermost condition. + for blockOffset in ifConfigBlockOffsets.sorted() { + guard let clauseMap = ifConfigGroups[blockOffset] else { continue } + let sortedClauses = clauseMap.sorted(by: { $0.key < $1.key }) + + var lines = [String]() + for (_, (clauseType, clauseEntities)) in sortedClauses { + switch clauseType { + case .if(let condition): + lines.append("#if \(condition)") + case .elseif(let condition): + lines.append("#elseif \(condition)") + case .else: + lines.append("#else") + } + for entity in clauseEntities { + let mockModel = entity.model() + if let mockString = mockModel.render( + context: .init(), + arguments: arguments + ), !mockString.isEmpty { + lines.append(mockString) + } + } } + lines.append("#endif") + completion(lines.joined(separator: "\n"), blockOffset) } } diff --git a/Sources/MockoloFramework/Parsers/SwiftSyntaxExtensions.swift b/Sources/MockoloFramework/Parsers/SwiftSyntaxExtensions.swift index e4cf2798..b5d53406 100644 --- a/Sources/MockoloFramework/Parsers/SwiftSyntaxExtensions.swift +++ b/Sources/MockoloFramework/Parsers/SwiftSyntaxExtensions.swift @@ -735,10 +735,7 @@ final class EntityVisitor: SyntaxVisitor { } 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) - } + processProtocol(node, ifConfigContext: nil) return .skipChildren } @@ -751,19 +748,7 @@ final class EntityVisitor: SyntaxVisitor { } override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind { - if scanAsMockfile || 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) { - entities.append(ent) - } - } else { - if declType == .classType || declType == .all { - let metadata = node.annotationMetadata(with: annotation) - if let ent = Entity.node(with: node, filepath: path, isPrivate: node.isPrivate, isFinal: node.isFinal, metadata: metadata, processed: false) { - entities.append(ent) - } - } - } + processClass(node, ifConfigContext: nil) return node.genericParameterClause != nil ? .skipChildren : .visitChildren } @@ -772,7 +757,6 @@ final class EntityVisitor: SyntaxVisitor { } override func visit(_ node: ImportDeclSyntax) -> SyntaxVisitorContinueKind { - // Top-level import (not inside #if) if let `import` = Import(line: node.trimmedDescription) { imports.append(.simple(`import`)) } @@ -782,48 +766,82 @@ final class EntityVisitor: SyntaxVisitor { override func visit(_ node: IfConfigDeclSyntax) -> SyntaxVisitorContinueKind { // Check if this is a file macro that should be ignored if let firstCondition = node.clauses.first?.condition?.trimmedDescription, - firstCondition == fileMacro { + !fileMacro.isEmpty, firstCondition == fileMacro { return .visitChildren } - // Parse conditional import block recursively - let block = parseIfConfigDecl(node) - imports.append(.conditional(block)) + let importClauses = processTopLevelIfConfig(node) + let hasImportContent = importClauses.contains { !$0.contents.isEmpty } + if hasImportContent { + imports.append(.conditional(ConditionalImportBlock(clauses: importClauses, offset: node.offset))) + } return .skipChildren } - /// Recursively parses an IfConfigDeclSyntax into a ConditionalImportBlock - private func parseIfConfigDecl(_ node: IfConfigDeclSyntax) -> ConditionalImportBlock { - var clauseList = [ConditionalImportBlock.Clause]() + /// Processes a top-level #if block, collecting imports as conditional blocks + /// and tagging discovered entities with their #if context. + /// Returns the import clauses for this block (used for nesting). + @discardableResult + private func processTopLevelIfConfig(_ node: IfConfigDeclSyntax) -> [ConditionalImportBlock.Clause] { + var importClauses = [ConditionalImportBlock.Clause]() - for cl in node.clauses { - guard let clauseType = IfClauseType(cl) else { - continue - } + for (clauseIndex, cl) in node.clauses.enumerated() { + guard let clauseType = IfClauseType(cl) else { continue } + let context = IfConfigContext(blockOffset: node.offset, clauseType: clauseType, clauseIndex: clauseIndex) + + var clauseImports = [ImportContent]() - var contents = [ImportContent]() if let list = cl.elements?.as(CodeBlockItemListSyntax.self) { for el in list { if let importItem = el.item.as(ImportDeclSyntax.self) { - // Simple import if let imp = Import(line: importItem.trimmedDescription) { - contents.append(.simple(imp)) + clauseImports.append(.simple(imp)) + } + } else if let protocolDecl = el.item.as(ProtocolDeclSyntax.self) { + processProtocol(protocolDecl, ifConfigContext: context) + } else if let classDecl = el.item.as(ClassDeclSyntax.self) { + processClass(classDecl, ifConfigContext: context) + } else if let nestedIfConfig = el.item.as(IfConfigDeclSyntax.self) { + // Recurse: collect nested imports and discover nested entities + let nestedClauses = processTopLevelIfConfig(nestedIfConfig) + let hasNestedImports = nestedClauses.contains { !$0.contents.isEmpty } + if hasNestedImports { + let nestedBlock = ConditionalImportBlock(clauses: nestedClauses, offset: nestedIfConfig.offset) + clauseImports.append(.conditional(nestedBlock)) } - } else if let nested = el.item.as(IfConfigDeclSyntax.self) { - // Nested #if block (recursive) - let nestedBlock = parseIfConfigDecl(nested) - contents.append(.conditional(nestedBlock)) } } } - clauseList.append(ConditionalImportBlock.Clause( - type: clauseType, - contents: contents - )) + importClauses.append(ConditionalImportBlock.Clause(type: clauseType, contents: clauseImports)) } - return ConditionalImportBlock(clauses: clauseList, offset: node.offset) + return importClauses + } + + private func processProtocol(_ node: ProtocolDeclSyntax, ifConfigContext: IfConfigContext?) { + let metadata = node.annotationMetadata(with: annotation) + if let ent = Entity.node(with: node, filepath: path, isPrivate: node.isPrivate, isFinal: false, metadata: metadata, processed: false) { + ent.ifConfigContext = ifConfigContext + entities.append(ent) + } + } + + private func processClass(_ node: ClassDeclSyntax, ifConfigContext: IfConfigContext?) { + if scanAsMockfile || node.nameText.hasSuffix("Mock") { + if let ent = Entity.node(with: node, filepath: path, isPrivate: node.isPrivate, isFinal: false, metadata: nil, processed: true) { + ent.ifConfigContext = ifConfigContext + entities.append(ent) + } + } else { + if declType == .classType || declType == .all { + let metadata = node.annotationMetadata(with: annotation) + if let ent = Entity.node(with: node, filepath: path, isPrivate: node.isPrivate, isFinal: node.isFinal, metadata: metadata, processed: false) { + ent.ifConfigContext = ifConfigContext + entities.append(ent) + } + } + } } override func visit(_ node: InitializerDeclSyntax) -> SyntaxVisitorContinueKind { diff --git a/Tests/TestConditionalImportBlocks/ConditionalImportBlocksTests.swift b/Tests/TestConditionalImportBlocks/ConditionalImportBlocksTests.swift new file mode 100644 index 00000000..6f059326 --- /dev/null +++ b/Tests/TestConditionalImportBlocks/ConditionalImportBlocksTests.swift @@ -0,0 +1,25 @@ +import XCTest +@testable import MockoloFramework + +final class ConditionalImportBlocksTests: MockoloTestCase { + func testProtocolInsideIfBlockWithNonImportDeclaration() { + verify(srcContent: FixtureConditionalImportBlocks.protocolInIfBlock, + dstContent: FixtureConditionalImportBlocks.protocolInIfBlockMock) + } + func testConditionalImportBlockPreserved() { + verify(srcContent: FixtureConditionalImportBlocks.conditionalImportBlock, + dstContent: FixtureConditionalImportBlocks.conditionalImportBlockMock) + } + func testNestedIfBlocksWithMultipleProtocols() { + verify(srcContent: FixtureConditionalImportBlocks.nestedIfBlocks, + dstContent: FixtureConditionalImportBlocks.nestedIfBlocksMock) + } + func testIfBlockWithImportsAndProtocol() { + verify(srcContent: FixtureConditionalImportBlocks.ifBlockWithImportsAndProtocol, + dstContent: FixtureConditionalImportBlocks.ifBlockWithImportsAndProtocolMock) + } + func testMixedNestedBlocks() { + verify(srcContent: FixtureConditionalImportBlocks.mixedNestedBlocks, + dstContent: FixtureConditionalImportBlocks.mixedNestedBlocksMock) + } +} diff --git a/Tests/TestConditionalImportBlocks/FixtureConditionalImportBlocks.swift b/Tests/TestConditionalImportBlocks/FixtureConditionalImportBlocks.swift new file mode 100644 index 00000000..604babf6 --- /dev/null +++ b/Tests/TestConditionalImportBlocks/FixtureConditionalImportBlocks.swift @@ -0,0 +1,197 @@ +enum FixtureConditionalImportBlocks { + + /// Protocol inside a #if block that contains non-import declarations + static let protocolInIfBlock = + """ + #if os(iOS) + /// @mockable + public protocol PlatformProtocol { + func platformFunction() + } + #endif + """ + + /// Expected mock for protocol inside #if block — mock is wrapped in the same #if + static let protocolInIfBlockMock = + """ + #if os(iOS) + public class PlatformProtocolMock: PlatformProtocol { + public init() { } + + + public private(set) var platformFunctionCallCount = 0 + public var platformFunctionHandler: (() -> ())? + public func platformFunction() { + platformFunctionCallCount += 1 + if let platformFunctionHandler = platformFunctionHandler { + platformFunctionHandler() + } + } + } + #endif + """ + + /// Protocol inside a #if block containing only imports (should be treated as conditional import) + static let conditionalImportBlock = + """ + #if canImport(Foundation) + import Foundation + #endif + + /// @mockable + public protocol ServiceProtocol { + func execute() + } + """ + + /// Expected output with conditional import preserved and protocol mocked + static let conditionalImportBlockMock = + """ + #if canImport(Foundation) + import Foundation + #endif + + + public class ServiceProtocolMock: ServiceProtocol { + public init() { } + + + public private(set) var executeCallCount = 0 + public var executeHandler: (() -> ())? + public func execute() { + executeCallCount += 1 + if let executeHandler = executeHandler { + executeHandler() + } + } + } + """ + + /// Multiple protocols in nested #if blocks with mixed content + static let nestedIfBlocks = + """ + #if os(iOS) + /// @mockable + public protocol iOSProtocol { + func iosMethod() + } + #elseif os(macOS) + /// @mockable + public protocol macOSProtocol { + func macosMethod() + } + #endif + """ + + /// Expected mocks for both protocols, preserving #if/#elseif structure + static let nestedIfBlocksMock = + """ + #if os(iOS) + public class iOSProtocolMock: iOSProtocol { + public init() { } + + + public private(set) var iosMethodCallCount = 0 + public var iosMethodHandler: (() -> ())? + public func iosMethod() { + iosMethodCallCount += 1 + if let iosMethodHandler = iosMethodHandler { + iosMethodHandler() + } + } + } + #elseif os(macOS) + public class macOSProtocolMock: macOSProtocol { + public init() { } + + + public private(set) var macosMethodCallCount = 0 + public var macosMethodHandler: (() -> ())? + public func macosMethod() { + macosMethodCallCount += 1 + if let macosMethodHandler = macosMethodHandler { + macosMethodHandler() + } + } + } + #endif + """ + + /// #if block with imports and a protocol (should visit children and discover protocol) + static let ifBlockWithImportsAndProtocol = + """ + #if DEBUG + import XCTest + /// @mockable + public protocol DebugProtocol { + func debugFunction() + } + #endif + """ + + /// Import is captured as conditional import, mock is wrapped in #if + static let ifBlockWithImportsAndProtocolMock = + """ + #if DEBUG + import XCTest + #endif + + + #if DEBUG + public class DebugProtocolMock: DebugProtocol { + public init() { } + + + public private(set) var debugFunctionCallCount = 0 + public var debugFunctionHandler: (() -> ())? + public func debugFunction() { + debugFunctionCallCount += 1 + if let debugFunctionHandler = debugFunctionHandler { + debugFunctionHandler() + } + } + } + #endif + """ + + /// Nested #if blocks where inner only contains imports + static let mixedNestedBlocks = + """ + #if os(iOS) + #if DEBUG + import XCTest + #endif + /// @mockable + public protocol MixedProtocol { + func mixedMethod() + } + #endif + """ + + /// Nested import block preserved, mock wrapped in outer #if + static let mixedNestedBlocksMock = + """ + #if os(iOS) + #if DEBUG + import XCTest + #endif + #endif + + + #if os(iOS) + public class MixedProtocolMock: MixedProtocol { + public init() { } + + + public private(set) var mixedMethodCallCount = 0 + public var mixedMethodHandler: (() -> ())? + public func mixedMethod() { + mixedMethodCallCount += 1 + if let mixedMethodHandler = mixedMethodHandler { + mixedMethodHandler() + } + } + } + #endif + """ +}