Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Sources/MockoloFramework/Models/ParsedEntity.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
58 changes: 55 additions & 3 deletions Sources/MockoloFramework/Operations/TemplateRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Int64>()
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)
}
}
100 changes: 59 additions & 41 deletions Sources/MockoloFramework/Parsers/SwiftSyntaxExtensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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
}

Expand All @@ -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`))
}
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading