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
24 changes: 19 additions & 5 deletions Sources/MockoloFramework/Models/ConditionalImportBlock.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,34 @@
/// Represents import content: either a simple import statement or a nested conditional block
indirect enum ImportContent {
case simple(Import)
case conditional(ConditionalImportBlock)
case conditional(ConditionalBlock)
}

/// Represents a conditional import block (#if/#elseif/#else/#endif)
struct ConditionalImportBlock {
/// Represents a single clause in a conditional import block
/// Represents a conditional compilation block (#if/#elseif/#else/#endif) that owns
/// both imports and entities found within its clauses.
struct ConditionalBlock {
/// Represents a single clause in a conditional compilation block
struct Clause {
var type: IfClauseType
var contents: [ImportContent]
var imports: [ImportContent]
var entities: [Entity]
}

let clauses: [Clause]
let offset: Int64

/// Whether any clause (including nested blocks) contains entities
var containsEntities: Bool {
clauses.contains { clause in
!clause.entities.isEmpty || clause.imports.contains { content in
if case .conditional(let nested) = content {
return nested.containsEntities
}
return false
}
}
}

init(clauses: [Clause], offset: Int64) {
self.clauses = clauses
self.offset = offset
Expand Down
22 changes: 22 additions & 0 deletions Sources/MockoloFramework/Operations/Generator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -148,8 +148,30 @@ public func generate(sourceDirs: [String],

signpost_begin(name: "Render models")
log("Render models with templates...", level: .info)

// Extract conditional blocks that contain entities from relevant source files
var conditionalEntityBlocks = [ConditionalBlock]()
func collectEntityBlocks(from contents: [ImportContent]) {
for content in contents {
if case .conditional(let block) = content {
if block.containsEntities {
conditionalEntityBlocks.append(block)
}
for clause in block.clauses {
collectEntityBlocks(from: clause.imports)
}
}
}
}
for (path, parsedImports) in pathToImportsMap {
guard relevantPaths.contains(path) else { continue }
collectEntityBlocks(from: parsedImports)
}
conditionalEntityBlocks.sort(by: { $0.offset < $1.offset })

renderTemplates(
entities: resolvedEntities,
conditionalBlocks: conditionalEntityBlocks,
arguments: .init(
useTemplateFunc: useTemplateFunc,
allowSetCallCount: allowSetCallCount,
Expand Down
16 changes: 11 additions & 5 deletions Sources/MockoloFramework/Operations/ImportsHandler.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -98,7 +98,12 @@ private func renderImportContents(
resolveAccumulatedSimpleImports()

var result = ""
var hasImportOutput = false
for clause in block.clauses {
let rendered = renderImportContents(clause.imports, excludeImports: excludeImports, testableImports: testableImports)
if !rendered.isEmpty {
hasImportOutput = true
}
switch clause.type {
case .if(let condition):
result += "#if \(condition)\n"
Expand All @@ -107,12 +112,13 @@ private func renderImportContents(
case .else:
result += "#else\n"
}
// Recursively render nested block
result += renderImportContents(clause.contents, excludeImports: excludeImports, testableImports: testableImports)
result += rendered
result += "\n"
}
result += "#endif"
clauseLines.append(result)
if hasImportOutput {
clauseLines.append(result)
}
}
}
resolveAccumulatedSimpleImports()
Expand All @@ -126,7 +132,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(\.imports))
}
}
}
92 changes: 89 additions & 3 deletions Sources/MockoloFramework/Operations/TemplateRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,106 @@
// limitations under the License.
//

import Foundation

/// Renders models with templates for output

func renderTemplates(entities: [ResolvedEntity],
conditionalBlocks: [ConditionalBlock],
arguments: GenerationArguments,
completion: @escaping (String, Int64) -> ()) {
scan(entities) { (resolvedEntity, lock) in
// Build lookup from entity name to resolved entity
let resolvedByName = Dictionary(
entities.map { ($0.key, $0) },
uniquingKeysWith: { $1 }
)

// Collect names of entities that live inside conditional blocks
var conditionalEntityNames = Set<String>()
func collectEntityNames(from blocks: [ConditionalBlock]) {
for block in blocks {
for clause in block.clauses {
for entity in clause.entities {
conditionalEntityNames.insert(entity.entityNode.nameText)
}
for content in clause.imports {
if case .conditional(let nested) = content {
collectEntityNames(from: [nested])
}
}
}
}
}
collectEntityNames(from: conditionalBlocks)

// Render conditional blocks, preserving #if/#elseif/#else/#endif structure
func renderBlock(_ block: ConditionalBlock) -> String? {
var lines = [String]()
var blockHasOutput = false

for clause in block.clauses {
var clauseLines = [String]()

// Render entities in this clause
for entity in clause.entities {
if let resolved = resolvedByName[entity.entityNode.nameText] {
let mockModel = resolved.model()
if let mockString = mockModel.render(
context: .init(),
arguments: arguments
), !mockString.isEmpty {
clauseLines.append(mockString)
}
}
}

// Recurse into nested conditional blocks
for content in clause.imports {
if case .conditional(let nested) = content {
if let nestedOutput = renderBlock(nested) {
clauseLines.append(nestedOutput)
}
}
}

guard !clauseLines.isEmpty else { continue }
blockHasOutput = true

switch clause.type {
case .if(let condition):
lines.append("#if \(condition)")
case .elseif(let condition):
lines.append("#elseif \(condition)")
case .else:
lines.append("#else")
}
lines.append(contentsOf: clauseLines)
}

guard blockHasOutput else { return nil }
lines.append("#endif")
return lines.joined(separator: "\n")
}

for block in conditionalBlocks {
if let rendered = renderBlock(block) {
completion(rendered, block.offset)
}
}

// Render standalone entities (not inside any conditional block)
let standalone = entities.filter { !conditionalEntityNames.contains($0.key) }

let lock = NSLock()
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()
}
}
}
89 changes: 52 additions & 37 deletions Sources/MockoloFramework/Parsers/SwiftSyntaxExtensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -735,8 +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) {
if let ent = makeProtocolEntity(node) {
entities.append(ent)
}
return .skipChildren
Expand All @@ -751,18 +750,8 @@ 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)
}
}
if let ent = makeClassEntity(node) {
entities.append(ent)
}
return node.genericParameterClause != nil ? .skipChildren : .visitChildren
}
Expand All @@ -772,7 +761,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 +770,75 @@ 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 clauses = processTopLevelIfConfig(node)
let hasContent = clauses.contains { !$0.imports.isEmpty || !$0.entities.isEmpty }
if hasContent {
imports.append(.conditional(ConditionalBlock(clauses: clauses, 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 and entities into clauses.
/// Entities are also added to `self.entities` so they appear in the protocol map.
private func processTopLevelIfConfig(_ node: IfConfigDeclSyntax) -> [ConditionalBlock.Clause] {
var result = [ConditionalBlock.Clause]()

for cl in node.clauses {
guard let clauseType = IfClauseType(cl) else {
continue
}
guard let clauseType = IfClauseType(cl) else { continue }

var clauseImports = [ImportContent]()
var clauseEntities = [Entity]()

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) {
if let ent = makeProtocolEntity(protocolDecl) {
clauseEntities.append(ent)
}
} else if let classDecl = el.item.as(ClassDeclSyntax.self) {
if let ent = makeClassEntity(classDecl) {
clauseEntities.append(ent)
}
} else if let nestedIfConfig = el.item.as(IfConfigDeclSyntax.self) {
let nestedClauses = processTopLevelIfConfig(nestedIfConfig)
let hasContent = nestedClauses.contains { !$0.imports.isEmpty || !$0.entities.isEmpty }
if hasContent {
clauseImports.append(.conditional(ConditionalBlock(clauses: nestedClauses, offset: nestedIfConfig.offset)))
}
} 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
))
// Also register clause entities in the flat list for the protocol map
entities.append(contentsOf: clauseEntities)

result.append(ConditionalBlock.Clause(type: clauseType, imports: clauseImports, entities: clauseEntities))
}

return ConditionalImportBlock(clauses: clauseList, offset: node.offset)
return result
}

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)
}

private func makeClassEntity(_ node: ClassDeclSyntax) -> Entity? {
if scanAsMockfile || node.nameText.hasSuffix("Mock") {
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 nil
}

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