diff --git a/Sources/MockoloFramework/Models/ConditionalBlock.swift b/Sources/MockoloFramework/Models/ConditionalBlock.swift new file mode 100644 index 00000000..7815e520 --- /dev/null +++ b/Sources/MockoloFramework/Models/ConditionalBlock.swift @@ -0,0 +1,49 @@ +// +// 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. +// + +/// One generic conditional-compilation structure (`#if`/`#elseif`/`#else`), specialized by leaf +/// type. Member-level (`IfMacroModel`), import-level, and top-level `#if` handling all reuse this +/// so the clause/branch shape — and directive emission — live in exactly one place. +struct ConditionalBlock { + /// A single `#if` / `#elseif` / `#else` clause and the leaves declared under it. + struct Clause { + var type: IfClauseType + var elements: [Leaf] + } + + let clauses: [Clause] + let offset: Int64 +} + +extension IfClauseType { + /// The directive line (`#if`/`#elseif`/`#else`) for this clause at the given indent level. + /// Centralizes what used to be duplicated switches in `IfMacroTemplate` and `ImportsHandler`. + func directiveLine(indent: Int) -> String { + switch self { + case .if(let condition): + return "\(indent.tab)#if \(condition)" + case .elseif(let condition): + return "\(indent.tab)#elseif \(condition)" + case .else: + return "\(indent.tab)#else" + } + } + + /// The closing `#endif` line at the given indent level. + static func endIfLine(indent: Int) -> String { + "\(indent.tab)#endif" + } +} diff --git a/Sources/MockoloFramework/Models/ConditionalImportBlock.swift b/Sources/MockoloFramework/Models/ConditionalImportBlock.swift index f5e87a91..7fba39b4 100644 --- a/Sources/MockoloFramework/Models/ConditionalImportBlock.swift +++ b/Sources/MockoloFramework/Models/ConditionalImportBlock.swift @@ -14,25 +14,9 @@ // limitations under the License. // -/// Represents import content: either a simple import statement or a nested conditional block +/// Represents import content: either a simple import statement or a nested conditional block. +/// Conditional import blocks reuse the shared `ConditionalBlock` structure (leaf == `ImportContent`). indirect enum ImportContent { case simple(Import) - case conditional(ConditionalImportBlock) -} - -/// Represents a conditional import block (#if/#elseif/#else/#endif) -struct ConditionalImportBlock { - /// Represents a single clause in a conditional import block - struct Clause { - var type: IfClauseType - var contents: [ImportContent] - } - - let clauses: [Clause] - let offset: Int64 - - init(clauses: [Clause], offset: Int64) { - self.clauses = clauses - self.offset = offset - } + case conditional(ConditionalBlock) } diff --git a/Sources/MockoloFramework/Models/IfMacroModel.swift b/Sources/MockoloFramework/Models/IfMacroModel.swift index f5c9246e..4cb4e2a4 100644 --- a/Sources/MockoloFramework/Models/IfMacroModel.swift +++ b/Sources/MockoloFramework/Models/IfMacroModel.swift @@ -31,39 +31,40 @@ enum IfClauseType { } final class IfMacroModel: Model { - /// Represents a single clause in a conditional compilation block - struct Clause { - var type: IfClauseType - var entities: [(String, Model)] - } + /// A member-level conditional-compilation clause: `(overloadingResolvedName, Model)` leaves. + typealias Clause = ConditionalBlock<(String, Model)>.Clause + + let block: ConditionalBlock<(String, Model)> - let clauses: [Clause] - let offset: Int64 + var clauses: [Clause] { block.clauses } + var offset: Int64 { block.offset } var modelType: ModelType { .macro } - + var name: String { - clauses.first?.type.condition ?? "" + block.clauses.first?.type.condition ?? "" } var fullName: String { - clauses.flatMap(\.entities).map { $0.0 }.joined(separator: "_") + block.clauses.flatMap(\.elements).map { $0.0 }.joined(separator: "_") + } + + init(block: ConditionalBlock<(String, Model)>) { + self.block = block } /// Creates an IfMacroModel with multiple clauses - init(clauses: [Clause], offset: Int64) { - self.clauses = clauses - self.offset = offset + convenience init(clauses: [Clause], offset: Int64) { + self.init(block: ConditionalBlock(clauses: clauses, offset: offset)) } /// Initializer for simple #if blocks convenience init(name: String, offset: Int64, entities: [(String, Model)]) { - let clause = Clause(type: .if(name), entities: entities) - self.init(clauses: [clause], offset: offset) + self.init(clauses: [Clause(type: .if(name), elements: entities)], offset: offset) } func render( diff --git a/Sources/MockoloFramework/Models/TopLevelConditionalLeaf.swift b/Sources/MockoloFramework/Models/TopLevelConditionalLeaf.swift new file mode 100644 index 00000000..95ee6d24 --- /dev/null +++ b/Sources/MockoloFramework/Models/TopLevelConditionalLeaf.swift @@ -0,0 +1,85 @@ +// +// 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. +// + +/// A leaf of a top-level `#if` block. Unlike member-level (`Model`) or import-only +/// (`ImportContent`) blocks, a top-level clause can mix imports and declarations, so its leaf is a +/// small union that preserves source order within the clause. +indirect enum TopLevelConditionalLeaf { + case `import`(Import) + case entity(Entity) + case nested(ConditionalBlock) +} + +extension ConditionalBlock where Leaf == TopLevelConditionalLeaf { + /// Whether any clause (recursively) declares a mockable entity. + var containsEntities: Bool { + clauses.contains { clause in + clause.elements.contains { leaf in + switch leaf { + case .entity: + return true + case .nested(let nested): + return nested.containsEntities + case .import: + return false + } + } + } + } + + /// All entity offsets declared in this block, so flat rendering can skip the entities that are + /// rendered (wrapped in their `#if` directives) by `renderConditionalBlocks`. + func entityOffsets() -> [Int64] { + clauses.flatMap { clause in + clause.elements.flatMap { leaf -> [Int64] in + switch leaf { + case .entity(let entity): + return [entity.entityNode.offset] + case .nested(let nested): + return nested.entityOffsets() + case .import: + return [] + } + } + } + } + + /// Lowers to the import pipeline, preserving the `#if`/`#elseif`/`#else` structure so + /// conditional imports keep their directives. Returns nil when no clause carries an import, so a + /// declaration-only block never emits a stray empty import block. + func loweredToImports() -> ConditionalBlock? { + var hasImport = false + let importClauses = clauses.map { clause -> ConditionalBlock.Clause in + var contents: [ImportContent] = [] + for leaf in clause.elements { + switch leaf { + case .import(let imp): + contents.append(.simple(imp)) + hasImport = true + case .nested(let nested): + if let lowered = nested.loweredToImports() { + contents.append(.conditional(lowered)) + hasImport = true + } + case .entity: + break + } + } + return ConditionalBlock.Clause(type: clause.type, elements: contents) + } + return hasImport ? ConditionalBlock(clauses: importClauses, offset: offset) : nil + } +} diff --git a/Sources/MockoloFramework/Operations/Generator.swift b/Sources/MockoloFramework/Operations/Generator.swift index 6a8becb0..b9d743d3 100644 --- a/Sources/MockoloFramework/Operations/Generator.swift +++ b/Sources/MockoloFramework/Operations/Generator.swift @@ -58,12 +58,13 @@ public func generate(sourceDirs: [String], var annotatedProtocolMap = [String: Entity]() var pathToImportsMap = ImportMap() var relevantPaths = [String]() + var topLevelConditionalBlocks = [ConditionalBlock]() signpost_begin(name: "Process input") let t0 = CFAbsoluteTimeGetCurrent() log("Process input mock files...", level: .info) if let mockFilePaths = mockFilePaths, !mockFilePaths.isEmpty { - parser.parseProcessedDecls(mockFilePaths, fileMacro: macro) { (elements, imports) in + parser.parseProcessedDecls(mockFilePaths, fileMacro: macro) { (elements, imports, _) in elements.forEach { element in parentMocks[element.entityNode.nameText] = element } @@ -94,7 +95,7 @@ public func generate(sourceDirs: [String], exclusionSuffixes: exclusionSuffixes, annotation: annotation, fileMacro: macro, - declType: declType) { (elements, imports) in + declType: declType) { (elements, imports, conditionals) in elements.forEach { element in protocolMap[element.entityNode.nameText] = element if element.isAnnotated { @@ -106,6 +107,7 @@ public func generate(sourceDirs: [String], pathToImportsMap[path] = importMap } } + topLevelConditionalBlocks.append(contentsOf: conditionals) } signpost_end(name: "Generate protocol map") let t2 = CFAbsoluteTimeGetCurrent() @@ -148,15 +150,31 @@ public func generate(sourceDirs: [String], signpost_begin(name: "Render models") log("Render models with templates...", level: .info) + let arguments = GenerationArguments( + useTemplateFunc: useTemplateFunc, + allowSetCallCount: allowSetCallCount, + mockFinal: mockFinal, + enableFuncArgsHistory: enableFuncArgsHistory, + disableCombineDefaultValues: disableCombineDefaultValues + ) + // Entities declared inside a top-level `#if` are rendered (wrapped in their directives) by + // `renderConditionalBlocks`; skip them in the flat pass so each mock is emitted exactly once. + let conditionalOffsets = Set(topLevelConditionalBlocks.flatMap { $0.entityOffsets() }) + let resolvedByOffset = Dictionary( + resolvedEntities.map { ($0.entity.entityNode.offset, $0) }, + uniquingKeysWith: { first, _ in first } + ) + let flatEntities = resolvedEntities.filter { !conditionalOffsets.contains($0.entity.entityNode.offset) } renderTemplates( - entities: resolvedEntities, - arguments: .init( - useTemplateFunc: useTemplateFunc, - allowSetCallCount: allowSetCallCount, - mockFinal: mockFinal, - enableFuncArgsHistory: enableFuncArgsHistory, - disableCombineDefaultValues: disableCombineDefaultValues - ) + entities: flatEntities, + arguments: arguments + ) { (mockString: String, offset: Int64) in + candidates.append((mockString, offset)) + } + renderConditionalBlocks( + topLevelConditionalBlocks, + resolvedByOffset: resolvedByOffset, + arguments: arguments ) { (mockString: String, offset: Int64) in candidates.append((mockString, offset)) } diff --git a/Sources/MockoloFramework/Operations/ImportsHandler.swift b/Sources/MockoloFramework/Operations/ImportsHandler.swift index 01bbdf58..55e30cfd 100644 --- a/Sources/MockoloFramework/Operations/ImportsHandler.swift +++ b/Sources/MockoloFramework/Operations/ImportsHandler.swift @@ -22,7 +22,7 @@ func handleImports(pathToImportsMap: ImportMap, testableImports: [String]?, relevantPaths: [String]) -> String { var topLevelImports: [Import] = [] - var conditionalBlocks: [ConditionalImportBlock] = [] + var conditionalBlocks: [ConditionalBlock] = [] // 1. Collect imports from all relevant files for (path, parsedImports) in pathToImportsMap { @@ -99,19 +99,12 @@ private func renderImportContents( var result = "" for clause in block.clauses { - switch clause.type { - case .if(let condition): - result += "#if \(condition)\n" - case .elseif(let condition): - result += "#elseif \(condition)\n" - case .else: - result += "#else\n" - } + result += clause.type.directiveLine(indent: 0) + "\n" // Recursively render nested block - result += renderImportContents(clause.contents, excludeImports: excludeImports, testableImports: testableImports) + result += renderImportContents(clause.elements, excludeImports: excludeImports, testableImports: testableImports) result += "\n" } - result += "#endif" + result += IfClauseType.endIfLine(indent: 0) clauseLines.append(result) } } @@ -126,7 +119,7 @@ private func visitModuleName(_ contents: [ImportContent]) -> [String] { case .simple(let `import`): return [`import`.moduleName] case .conditional(let block): - return visitModuleName(block.clauses.flatMap(\.contents)) + return visitModuleName(block.clauses.flatMap(\.elements)) } } } diff --git a/Sources/MockoloFramework/Operations/TemplateRenderer.swift b/Sources/MockoloFramework/Operations/TemplateRenderer.swift index 12379c14..b14f5667 100644 --- a/Sources/MockoloFramework/Operations/TemplateRenderer.swift +++ b/Sources/MockoloFramework/Operations/TemplateRenderer.swift @@ -31,3 +31,58 @@ func renderTemplates(entities: [ResolvedEntity], } } } + +/// Renders mocks for entities declared inside top-level `#if` blocks, re-wrapping each in the +/// original `#if`/`#elseif`/`#else` directives so the generated file compiles under the same +/// build configurations as the source. +func renderConditionalBlocks(_ blocks: [ConditionalBlock], + resolvedByOffset: [Int64: ResolvedEntity], + arguments: GenerationArguments, + completion: (String, Int64) -> ()) { + for block in blocks { + if let rendered = renderConditionalBlock(block, + resolvedByOffset: resolvedByOffset, + arguments: arguments, + indent: 0) { + completion(rendered, block.offset) + } + } +} + +private func renderConditionalBlock(_ block: ConditionalBlock, + resolvedByOffset: [Int64: ResolvedEntity], + arguments: GenerationArguments, + indent: Int) -> String? { + var lines: [String] = [] + var produced = false + for clause in block.clauses { + // Keep every clause so #elseif/#else stay syntactically valid even when a branch is empty. + lines.append(clause.type.directiveLine(indent: indent)) + for leaf in clause.elements { + switch leaf { + case .import: + // Imports are emitted by the imports section (ImportsHandler), not here. + break + case .entity(let entity): + if let resolved = resolvedByOffset[entity.entityNode.offset], + let mock = resolved.model().render(context: .init(), arguments: arguments), + !mock.isEmpty { + lines.append(mock) + produced = true + } + case .nested(let nested): + if let rendered = renderConditionalBlock(nested, + resolvedByOffset: resolvedByOffset, + arguments: arguments, + indent: indent) { + lines.append(rendered) + produced = true + } + } + } + } + // Drop a block that produced no mock (e.g. only non-annotated decls) rather than emit an empty #if. + guard produced else { return nil } + lines.append(IfClauseType.endIfLine(indent: indent)) + return lines.joined(separator: "\n") +} diff --git a/Sources/MockoloFramework/Parsers/SourceParser.swift b/Sources/MockoloFramework/Parsers/SourceParser.swift index ef242af8..dfdf3bbe 100644 --- a/Sources/MockoloFramework/Parsers/SourceParser.swift +++ b/Sources/MockoloFramework/Parsers/SourceParser.swift @@ -30,7 +30,7 @@ class SourceParser { /// @param completion:The block to be executed on completion func parseProcessedDecls(_ paths: [String], fileMacro: String?, - completion: @escaping ([Entity], ImportMap?) -> ()) { + completion: @escaping ([Entity], ImportMap?, [ConditionalBlock]) -> ()) { scan(paths) { (path, lock) in self.generateASTs(path, annotation: "", fileMacro: fileMacro, declType: .classType, scanAsMockfile: true, lock: lock, completion: completion) } @@ -49,7 +49,7 @@ class SourceParser { annotation: String, fileMacro: String?, declType: FindTargetDeclType, - completion: @escaping ([Entity], ImportMap?) -> ()) { + completion: @escaping ([Entity], ImportMap?, [ConditionalBlock]) -> ()) { guard !paths.isEmpty else { return } scan(paths, isDirectory: isDirs) { (path, lock) in @@ -70,7 +70,7 @@ class SourceParser { declType: FindTargetDeclType, scanAsMockfile: Bool = false, lock: NSLock?, - completion: @escaping ([Entity], ImportMap?) -> ()) { + completion: @escaping ([Entity], ImportMap?, [ConditionalBlock]) -> ()) { guard path.shouldParse(with: exclusionSuffixes) else { return } @@ -96,7 +96,7 @@ class SourceParser { lock?.lock() defer {lock?.unlock()} - completion(results, [path: importMap]) + completion(results, [path: importMap], treeVisitor.topLevelConditionals) } private func containsDecl(_ decl: Data?, in path: String) -> Bool { diff --git a/Sources/MockoloFramework/Parsers/SwiftSyntaxExtensions.swift b/Sources/MockoloFramework/Parsers/SwiftSyntaxExtensions.swift index c6cc5edf..01effd12 100644 --- a/Sources/MockoloFramework/Parsers/SwiftSyntaxExtensions.swift +++ b/Sources/MockoloFramework/Parsers/SwiftSyntaxExtensions.swift @@ -261,7 +261,7 @@ extension IfConfigDeclSyntax { clauseList.append(IfMacroModel.Clause( type: clauseType, - entities: uniqueSubModels + elements: uniqueSubModels )) } @@ -765,6 +765,7 @@ extension TypeAliasDeclSyntax { final class EntityVisitor: SyntaxVisitor { var entities: [Entity] = [] var imports: [ImportContent] = [] + var topLevelConditionals: [ConditionalBlock] = [] let annotation: String let fileMacro: String let path: String @@ -780,13 +781,19 @@ 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) { + if let ent = makeProtocolEntity(node) { entities.append(ent) } return .skipChildren } + /// Builds the `Entity` for a protocol declaration. Shared by the normal traversal and the + /// top-level `#if` parser so both paths discover protocols identically. + private func makeProtocolEntity(_ node: ProtocolDeclSyntax) -> Entity? { + let metadata = node.annotationMetadata(with: annotation) + return Entity.node(with: node, filepath: path, isPrivate: node.isPrivate, isFinal: false, metadata: metadata, processed: false) + } + override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind { return node.genericParameterClause != nil ? .skipChildren : .visitChildren } @@ -796,20 +803,23 @@ final class EntityVisitor: SyntaxVisitor { } override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind { + if let ent = makeClassEntity(node) { + entities.append(ent) + } + return node.genericParameterClause != nil ? .skipChildren : .visitChildren + } + + /// Builds the `Entity` for a class declaration (mock-file scan vs annotated source). Shared by + /// the normal traversal and the top-level `#if` parser. + private func makeClassEntity(_ node: ClassDeclSyntax) -> Entity? { 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) - } - } + return Entity.node(with: node, filepath: path, isPrivate: node.isPrivate, isFinal: false, metadata: nil, processed: true) + } else if declType == .classType || declType == .all { + let metadata = node.annotationMetadata(with: annotation) + return Entity.node(with: node, filepath: path, isPrivate: node.isPrivate, isFinal: node.isFinal, metadata: metadata, processed: false) } - return node.genericParameterClause != nil ? .skipChildren : .visitChildren + return nil } override func visit(_ node: ActorDeclSyntax) -> SyntaxVisitorContinueKind { @@ -831,44 +841,63 @@ final class EntityVisitor: SyntaxVisitor { return .visitChildren } - // Parse conditional import block recursively - let block = parseIfConfigDecl(node) - imports.append(.conditional(block)) + // Parse the block once, collecting imports AND declarations in source order. Treating a + // non-fileMacro `#if` as import-only (and skipping children) used to drop protocols/classes + // declared inside it — the top-level `#if` regression this restores. + let block = parseTopLevelIfConfig(node) + if block.containsEntities { + topLevelConditionals.append(block) + } + if let importBlock = block.loweredToImports() { + imports.append(.conditional(importBlock)) + } return .skipChildren } - /// Recursively parses an IfConfigDeclSyntax into a ConditionalImportBlock - private func parseIfConfigDecl(_ node: IfConfigDeclSyntax) -> ConditionalImportBlock { - var clauseList = [ConditionalImportBlock.Clause]() + /// Recursively parses a top-level `IfConfigDeclSyntax` into a `ConditionalBlock` whose leaves are + /// imports, mockable declarations (protocol/class), or nested blocks. Discovered entities are + /// also appended to `entities` so they flow through resolution; member parsing of those entities + /// happens later from their stored nodes (independent of this visitor), so members — including + /// nested member-level `#if` — are fully supported. + private func parseTopLevelIfConfig(_ node: IfConfigDeclSyntax) -> ConditionalBlock { + var clauseList = [ConditionalBlock.Clause]() for cl in node.clauses { guard let clauseType = IfClauseType(cl) else { continue } - var contents = [ImportContent]() + var elements = [TopLevelConditionalLeaf]() if let list = cl.elements?.as(CodeBlockItemListSyntax.self) { for el in list { - if let importItem = el.item.as(ImportDeclSyntax.self) { - // Simple import + let item = el.item + if let importItem = item.as(ImportDeclSyntax.self) { if let imp = Import(line: importItem.trimmedDescription) { - contents.append(.simple(imp)) + elements.append(.import(imp)) + } + } else if let proto = item.as(ProtocolDeclSyntax.self) { + if let ent = makeProtocolEntity(proto) { + entities.append(ent) + elements.append(.entity(ent)) + } + } else if let cls = item.as(ClassDeclSyntax.self) { + if let ent = makeClassEntity(cls) { + entities.append(ent) + elements.append(.entity(ent)) } - } else if let nested = el.item.as(IfConfigDeclSyntax.self) { - // Nested #if block (recursive) - let nestedBlock = parseIfConfigDecl(nested) - contents.append(.conditional(nestedBlock)) + } else if let nested = item.as(IfConfigDeclSyntax.self) { + elements.append(.nested(parseTopLevelIfConfig(nested))) } } } - clauseList.append(ConditionalImportBlock.Clause( + clauseList.append(ConditionalBlock.Clause( type: clauseType, - contents: contents + elements: elements )) } - return ConditionalImportBlock(clauses: clauseList, offset: node.offset) + return ConditionalBlock(clauses: clauseList, offset: node.offset) } override func visit(_ node: InitializerDeclSyntax) -> SyntaxVisitorContinueKind { diff --git a/Sources/MockoloFramework/Templates/IfMacroTemplate.swift b/Sources/MockoloFramework/Templates/IfMacroTemplate.swift index ad093fd0..7dbc2b9c 100644 --- a/Sources/MockoloFramework/Templates/IfMacroTemplate.swift +++ b/Sources/MockoloFramework/Templates/IfMacroTemplate.swift @@ -23,20 +23,10 @@ extension IfMacroModel { var lines = [String]() for clause in clauses { - // Render the directive line - let directive: String - switch clause.type { - case .if(let condition): - directive = "\(1.tab)#if \(condition)" - case .elseif(let condition): - directive = "\(1.tab)#elseif \(condition)" - case .else: - directive = "\(1.tab)#else" - } - lines.append(directive) + lines.append(clause.type.directiveLine(indent: 1)) // Render entities in this clause - let rendered = clause.entities + let rendered = clause.elements .compactMap { model in model.1.render( context: .init( @@ -54,7 +44,7 @@ extension IfMacroModel { } } - lines.append("\(1.tab)#endif") + lines.append(IfClauseType.endIfLine(indent: 1)) return lines.joined(separator: "\n") } } diff --git a/Tests/TestConditionalCompilation/ConditionalCompilationTests.swift b/Tests/TestConditionalCompilation/ConditionalCompilationTests.swift new file mode 100644 index 00000000..a864a2c1 --- /dev/null +++ b/Tests/TestConditionalCompilation/ConditionalCompilationTests.swift @@ -0,0 +1,38 @@ +import XCTest + +/// Tests for declarations declared inside *top-level* `#if`/`#elseif`/`#else` blocks. These guard +/// the regression where such declarations were skipped (treated as import-only), and verify the +/// generated mocks are re-wrapped in the original directives. +class ConditionalCompilationTests: MockoloTestCase { + + // (a) Regression core: a protocol inside `#if DEBUG` must produce a mock wrapped in `#if DEBUG`. + func testTopLevelIfProtocolGeneratesWrappedMock() { + verify(srcContent: topLevelIfProtocol, + dstContent: topLevelIfProtocolMock) + } + + // (b) `#if`/`#elseif`/`#else` each declaring a protocol → one block, each branch its own mock. + func testTopLevelIfElseIfElseProtocols() { + verify(srcContent: ifElseIfElseProtocols, + dstContent: ifElseIfElseProtocolsMock) + } + + // (c) Imports + a protocol mixed in one `#if`: both the import and the mock stay guarded. + func testTopLevelIfImportAndProtocol() { + verify(srcContent: ifImportAndProtocol, + dstContent: ifImportAndProtocolMock) + } + + // (d) Nested top-level `#if` preserves both directive levels around the mock. + func testNestedTopLevelIf() { + verify(srcContent: nestedTopLevelIf, + dstContent: nestedTopLevelIfMock) + } + + // (f) A class inside a top-level `#if` generates all members, including a member-level `#if`. + func testClassMembersInsideTopLevelIf() { + verify(srcContent: classInTopLevelIf, + dstContent: classInTopLevelIfMock, + declType: .all) + } +} diff --git a/Tests/TestConditionalCompilation/FixtureConditionalCompilation.swift b/Tests/TestConditionalCompilation/FixtureConditionalCompilation.swift new file mode 100644 index 00000000..77c08d00 --- /dev/null +++ b/Tests/TestConditionalCompilation/FixtureConditionalCompilation.swift @@ -0,0 +1,203 @@ +import MockoloFramework + +// (a) A protocol declared inside a top-level `#if` is discovered, and its generated mock is wrapped +// in the same `#if`. This is the core regression (previously the block was treated as import-only). +let topLevelIfProtocol = """ +#if DEBUG +/// @mockable +public protocol DebugService { + func fetch(id: Int) -> String +} +#endif +""" + +let topLevelIfProtocolMock = """ +#if DEBUG +public class DebugServiceMock: DebugService { + public init() { } + public private(set) var fetchCallCount = 0 + public var fetchHandler: ((Int) -> String)? + public func fetch(id: Int) -> String { + fetchCallCount += 1 + if let fetchHandler = fetchHandler { + return fetchHandler(id) + } + return "" + } +} +#endif +""" + +// (b) `#if` / `#elseif` / `#else` each declaring a different protocol: one block, each branch keeps +// its own mock. Proves branches cannot be wrapped independently. +let ifElseIfElseProtocols = """ +#if ALPHA +/// @mockable +public protocol AService { + func a() +} +#elseif BETA +/// @mockable +public protocol BService { + func b() +} +#else +/// @mockable +public protocol CService { + func c() +} +#endif +""" + +let ifElseIfElseProtocolsMock = """ +#if ALPHA +public class AServiceMock: AService { + public init() { } + public private(set) var aCallCount = 0 + public var aHandler: (() -> ())? + public func a() { + aCallCount += 1 + if let aHandler = aHandler { + aHandler() + } + } +} +#elseif BETA +public class BServiceMock: BService { + public init() { } + public private(set) var bCallCount = 0 + public var bHandler: (() -> ())? + public func b() { + bCallCount += 1 + if let bHandler = bHandler { + bHandler() + } + } +} +#else +public class CServiceMock: CService { + public init() { } + public private(set) var cCallCount = 0 + public var cHandler: (() -> ())? + public func c() { + cCallCount += 1 + if let cHandler = cHandler { + cHandler() + } + } +} +#endif +""" + +// (c) Imports and a protocol mixed in one `#if`: the import is lowered to the imports section (still +// guarded), and the protocol mock is wrapped in its `#if` in the entity section. +let ifImportAndProtocol = """ +#if DEBUG +import Combine +/// @mockable +public protocol CImportService { + func fetch() +} +#endif +""" + +let ifImportAndProtocolMock = """ +#if DEBUG +import Combine +#endif +#if DEBUG +public class CImportServiceMock: CImportService { + public init() { } + public private(set) var fetchCallCount = 0 + public var fetchHandler: (() -> ())? + public func fetch() { + fetchCallCount += 1 + if let fetchHandler = fetchHandler { + fetchHandler() + } + } +} +#endif +""" + +// (d) Nested top-level `#if`: both directive levels are preserved around the mock. +let nestedTopLevelIf = """ +#if ALPHA +#if BETA +/// @mockable +public protocol NestedService { + func n() +} +#endif +#endif +""" + +let nestedTopLevelIfMock = """ +#if ALPHA +#if BETA +public class NestedServiceMock: NestedService { + public init() { } + public private(set) var nCallCount = 0 + public var nHandler: (() -> ())? + public func n() { + nCallCount += 1 + if let nHandler = nHandler { + nHandler() + } + } +} +#endif +#endif +""" + +// (f) A class declared inside a top-level `#if`, with members AND a member-level nested `#if`. +// Member parsing is independent of the top-level visitor, so all members (and the inner `#if`) are +// fully generated — this is the "class members inside `#if`" requirement. +let classInTopLevelIf = """ +#if DEBUG +/// @mockable +public class Repository { + public func load(id: Int) -> String { "" } + public var count: Int { 0 } + #if VERBOSE + public func verbose() -> Bool { false } + #endif +} +#endif +""" + +let classInTopLevelIfMock = """ +#if DEBUG +public class RepositoryMock: Repository { + public private(set) var loadCallCount = 0 + public var loadHandler: ((Int) -> String)? + public override func load(id: Int) -> String { + loadCallCount += 1 + if let loadHandler = loadHandler { + return loadHandler(id) + } + return "" + } + public var countHandler: (() -> Int)? + public override var count: Int { + get { + if let countHandler = countHandler { + return countHandler() + } + return 0 + } + } + #if VERBOSE + public private(set) var verboseCallCount = 0 + public var verboseHandler: (() -> Bool)? + public override func verbose() -> Bool { + verboseCallCount += 1 + if let verboseHandler = verboseHandler { + return verboseHandler() + } + return false + } + #endif +} +#endif +"""