Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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