diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..dfe0770 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Auto detect text files and perform LF normalization +* text=auto diff --git a/.swift-format b/.swift-format new file mode 100644 index 0000000..b731452 --- /dev/null +++ b/.swift-format @@ -0,0 +1,72 @@ +{ + "fileScopedDeclarationPrivacy": { + "accessLevel": "private" + }, + "indentConditionalCompilationBlocks": false, + "indentSwitchCaseLabels": false, + "indentation": { + "spaces": 4 + }, + "lineBreakAroundMultilineExpressionChainComponents": false, + "lineBreakBeforeControlFlowKeywords": false, + "lineBreakBeforeEachArgument": true, + "lineBreakBeforeEachGenericRequirement": false, + "lineBreakBetweenDeclarationAttributes": false, + "lineLength": 120, + "maximumBlankLines": 1, + "multiElementCollectionTrailingCommas": true, + "noAssignmentInExpressions": { + "allowedFunctions": [ + "XCTAssertNoThrow" + ] + }, + "prioritizeKeepingFunctionOutputTogether": true, + "reflowMultilineStringLiterals": "never", + "respectsExistingLineBreaks": true, + "rules": { + "AllPublicDeclarationsHaveDocumentation": false, + "AlwaysUseLiteralForEmptyCollectionInit": false, + "AlwaysUseLowerCamelCase": false, + "AmbiguousTrailingClosureOverload": false, + "BeginDocumentationCommentWithOneLineSummary": false, + "DoNotUseSemicolons": false, + "DontRepeatTypeInStaticProperties": false, + "FileScopedDeclarationPrivacy": true, + "FullyIndirectEnum": true, + "GroupNumericLiterals": true, + "IdentifiersMustBeASCII": true, + "NeverForceUnwrap": false, + "NeverUseForceTry": false, + "NeverUseImplicitlyUnwrappedOptionals": false, + "NoAccessLevelOnExtensionDeclaration": false, + "NoAssignmentInExpressions": true, + "NoBlockComments": false, + "NoCasesWithOnlyFallthrough": true, + "NoEmptyTrailingClosureParentheses": true, + "NoLabelsInCasePatterns": true, + "NoLeadingUnderscores": false, + "NoParensAroundConditions": true, + "NoPlaygroundLiterals": true, + "NoVoidReturnOnFunctionSignature": true, + "OmitExplicitReturns": false, + "OneCasePerLine": true, + "OneVariableDeclarationPerLine": true, + "OnlyOneTrailingClosureArgument": true, + "OrderedImports": true, + "ReplaceForEachWithForLoop": true, + "ReturnVoidInsteadOfEmptyTuple": true, + "TypeNamesShouldBeCapitalized": true, + "UseEarlyExits": false, + "UseExplicitNilCheckInConditions": true, + "UseLetInEveryBoundCaseVariable": false, + "UseShorthandTypeNames": true, + "UseSingleLinePropertyGetter": true, + "UseSynthesizedInitializer": false, + "UseTripleSlashForDocumentationComments": true, + "UseWhereClausesInForLoops": false, + "ValidateDocumentationComments": false + }, + "spacesAroundRangeFormationOperators": false, + "spacesBeforeEndOfLineComments": 2, + "version": 1 +} diff --git a/Package.resolved b/Package.resolved index 96793ae..b787898 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "f0bdad18f08b8438e54c541e12efce628281aa84df2fdc05c794ae30b579c24c", + "originHash" : "9a8b2e43912bb723fe640cc01f9bd94e48c9cf3a774b73885923fc042ba4c6cb", "pins" : [ { "identity" : "swift-argument-parser", diff --git a/Sources/SwiftAstGen/Defaults.swift b/Sources/SwiftAstGen/Defaults.swift index dbcb5df..6a9f016 100644 --- a/Sources/SwiftAstGen/Defaults.swift +++ b/Sources/SwiftAstGen/Defaults.swift @@ -1,4 +1,4 @@ struct Defaults { - static let defaultSrcDir = "." - static let defaultOutDir = "./ast_out" + static let defaultSrcDir = "." + static let defaultOutDir = "./ast_out" } diff --git a/Sources/SwiftAstGen/SwiftAstGen.swift b/Sources/SwiftAstGen/SwiftAstGen.swift index 25c3738..d0e8551 100644 --- a/Sources/SwiftAstGen/SwiftAstGen.swift +++ b/Sources/SwiftAstGen/SwiftAstGen.swift @@ -1,47 +1,52 @@ import ArgumentParser + import Foundation + import SwiftAstGenLib @main struct SwiftAstGen: ParsableCommand { - @Option( - name: [.customLong("src"), .customShort("i")], - help: "Source directory (default: `\(Defaults.defaultSrcDir)`).", - completion: .file(), - transform: URL.init(fileURLWithPath:)) - var src: URL = URL(fileURLWithPath: Defaults.defaultSrcDir) - - @Option( - name: [.customLong("output"), .customShort("o")], - help: "Output directory for generated AST json files (default: `\(Defaults.defaultOutDir)`).", - completion: .file(), - transform: URL.init(fileURLWithPath:)) - var output: URL = URL(fileURLWithPath: Defaults.defaultOutDir) - - @Flag( - name: [.customLong("prettyPrint"), .customShort("p")], - help: "Pretty print the generated AST json files.") - var prettyPrint: Bool = false - - @Flag( - name: [.customLong("scalaAstOnly"), .customShort("s")], - help: "Only print the generated Scala SwiftSyntax AST nodes.") - var scalaAstOnly: Bool = false - - func validate() throws { - guard FileManager.default.fileExists(atPath: src.path) else { - throw ValidationError("Directory does not exist: `\(src.path)`") - } - } -} + @Option( + name: [.customLong("src"), .customShort("i")], + help: "Source directory (default: `\(Defaults.defaultSrcDir)`).", + completion: .file(), + transform: URL.init(fileURLWithPath:) + ) + var src: URL = URL(fileURLWithPath: Defaults.defaultSrcDir) + + @Option( + name: [.customLong("output"), .customShort("o")], + help: "Output directory for generated AST json files (default: `\(Defaults.defaultOutDir)`).", + completion: .file(), + transform: URL.init(fileURLWithPath:) + ) + var output: URL = URL(fileURLWithPath: Defaults.defaultOutDir) + @Flag( + name: [.customLong("prettyPrint"), .customShort("p")], + help: "Pretty print the generated AST json files." + ) + var prettyPrint: Bool = false + + @Flag( + name: [.customLong("scalaAstOnly"), .customShort("s")], + help: "Only print the generated Scala SwiftSyntax AST nodes." + ) + var scalaAstOnly: Bool = false + + func validate() throws { + guard FileManager.default.fileExists(atPath: src.path) else { + throw ValidationError("Directory does not exist: `\(src.path)`") + } + } +} extension SwiftAstGen { - func run() throws { - if scalaAstOnly { - try ScalaAstGenerator().generate() - } else { - try SwiftAstGenerator(srcDir: src, outputDir: output, prettyPrint: prettyPrint).generate() - } - } + func run() throws { + if scalaAstOnly { + try ScalaAstGenerator().generate() + } else { + try SwiftAstGenerator(srcDir: src, outputDir: output, prettyPrint: prettyPrint).generate() + } + } } diff --git a/Sources/SwiftAstGenLib/PackageTestTargetParser.swift b/Sources/SwiftAstGenLib/PackageTestTargetParser.swift new file mode 100644 index 0000000..4ccc1e2 --- /dev/null +++ b/Sources/SwiftAstGenLib/PackageTestTargetParser.swift @@ -0,0 +1,91 @@ +import Foundation + +import SwiftParser + +/// Visitor that extracts test target information from Package.swift +import SwiftSyntax + +private class TestTargetVisitor: SyntaxVisitor { + var testTargetPaths: [String] = [] + + override func visit(_ node: FunctionCallExprSyntax) -> SyntaxVisitorContinueKind { + // Look for .testTarget(...) function calls + if let memberAccess = node.calledExpression.as(MemberAccessExprSyntax.self), + memberAccess.declName.baseName.text == "testTarget" + { + extractTestTargetInfo(from: node) + } + return .visitChildren + } + + private func extractTestTargetInfo(from functionCall: FunctionCallExprSyntax) { + var name: String? + var path: String? + + // Iterate through the labeled arguments + for argument in functionCall.arguments { + guard let label = argument.label?.text else { continue } + + switch label { + case "name": + // Extract the string literal value + if let stringExpr = argument.expression.as(StringLiteralExprSyntax.self), + let segment = stringExpr.segments.first?.as(StringSegmentSyntax.self) + { + name = segment.content.text + } + case "path": + // Extract the string literal value for path + if let stringExpr = argument.expression.as(StringLiteralExprSyntax.self), + let segment = stringExpr.segments.first?.as(StringSegmentSyntax.self) + { + path = segment.content.text + } + default: + break + } + } + + // If path is explicitly specified, use it; otherwise, use Tests/{name} + if let path = path { + testTargetPaths.append(path) + } else if let name = name { + testTargetPaths.append("Tests/\(name)") + } + } +} +public class PackageTestTargetParser { + + private let srcDir: URL + + public init(srcDir: URL) { + self.srcDir = srcDir + } + + /// Returns a list of all testTarget paths found in the Package.swift file at srcDir + public func getTestTargetPaths() -> [String] { + let packageSwiftUrl = srcDir.appendingPathComponent("Package.swift") + + guard FileManager.default.fileExists(atPath: packageSwiftUrl.path) else { + return [] + } + + do { + let content = try String(contentsOf: packageSwiftUrl, encoding: .utf8) + return parseTestTargets(from: content) + } catch { + return [] + } + } + + private func parseTestTargets(from content: String) -> [String] { + // Parse the Swift source code using SwiftParser + let sourceFile = Parser.parse(source: content) + + // Create a visitor and walk the syntax tree + let visitor = TestTargetVisitor(viewMode: .sourceAccurate) + visitor.walk(sourceFile) + + return visitor.testTargetPaths + } +} diff --git a/Sources/SwiftAstGenLib/RelativePath.swift b/Sources/SwiftAstGenLib/RelativePath.swift index 34a4387..70ea57e 100644 --- a/Sources/SwiftAstGenLib/RelativePath.swift +++ b/Sources/SwiftAstGenLib/RelativePath.swift @@ -1,27 +1,27 @@ import Foundation extension URL { - func relativePath(from base: URL) -> String? { - // Ensure that both URLs represent files: - guard self.isFileURL && base.isFileURL else { - return nil - } + func relativePath(from base: URL) -> String? { + // Ensure that both URLs represent files: + guard self.isFileURL && base.isFileURL else { + return nil + } - // Remove/replace "." and "..", make paths absolute: - let destComponents = self.standardized.resolvingSymlinksInPath().pathComponents - let baseComponents = base.standardized.resolvingSymlinksInPath().pathComponents + // Remove/replace "." and "..", make paths absolute: + let destComponents = self.standardized.resolvingSymlinksInPath().pathComponents + let baseComponents = base.standardized.resolvingSymlinksInPath().pathComponents - // Find number of common path components: - var i = 0 - while i < destComponents.count && i < baseComponents.count - && destComponents[i] == baseComponents[i] - { - i += 1 - } + // Find number of common path components: + var i = 0 + while i < destComponents.count && i < baseComponents.count + && destComponents[i] == baseComponents[i] + { + i += 1 + } - // Build relative path: - var relComponents = Array(repeating: "..", count: baseComponents.count - i) - relComponents.append(contentsOf: destComponents[i...]) - return relComponents.joined(separator: "/") - } + // Build relative path: + var relComponents = Array(repeating: "..", count: baseComponents.count - i) + relComponents.append(contentsOf: destComponents[i...]) + return relComponents.joined(separator: "/") + } } diff --git a/Sources/SwiftAstGenLib/SwiftAstGenerator.swift b/Sources/SwiftAstGenLib/SwiftAstGenerator.swift index fe63035..19b80b5 100644 --- a/Sources/SwiftAstGenLib/SwiftAstGenerator.swift +++ b/Sources/SwiftAstGenLib/SwiftAstGenerator.swift @@ -2,94 +2,100 @@ import Foundation public class SwiftAstGenerator { - private var srcDir: URL - private var outputDir: URL - private var prettyPrint: Bool - private let availableProcessors = ProcessInfo.processInfo.activeProcessorCount + private var srcDir: URL + private var outputDir: URL + private var prettyPrint: Bool + private var ignorePathsFromPackageSwift: [String] = [] + private let availableProcessors: Int = ProcessInfo.processInfo.activeProcessorCount - public init(srcDir: URL, outputDir: URL, prettyPrint: Bool) throws { - self.srcDir = srcDir - self.outputDir = outputDir - self.prettyPrint = prettyPrint - if !FileManager.default.fileExists(atPath: outputDir.path) { - try FileManager.default.createDirectory( - atPath: outputDir.path, - withIntermediateDirectories: true, - attributes: nil - ) - } - } + public init(srcDir: URL, outputDir: URL, prettyPrint: Bool) throws { + self.srcDir = srcDir + self.outputDir = outputDir + self.prettyPrint = prettyPrint + self.ignorePathsFromPackageSwift = PackageTestTargetParser(srcDir: srcDir).getTestTargetPaths() - private func ignoreDirectory(name: String) -> Bool { - let nameLowercased = name.lowercased() - return nameLowercased.contains("/.") - || nameLowercased.contains("/__") - || nameLowercased.contains("/tests/") - || nameLowercased.contains("/specs/") - || nameLowercased.contains("/test/") - || nameLowercased.contains("/spec/") - } + if !FileManager.default.fileExists(atPath: outputDir.path) { + try FileManager.default.createDirectory( + atPath: outputDir.path, + withIntermediateDirectories: true, + attributes: nil + ) + } + } - private func parseFile(fileUrl: URL) { - do { - let relativeFilePath = fileUrl.relativePath(from: srcDir)! - let astJsonString = try SyntaxParser.parse( - srcDir: srcDir, - fileUrl: fileUrl, - relativeFilePath: relativeFilePath, - prettyPrint: prettyPrint) - let outFileUrl = - outputDir - .appendingPathComponent(relativeFilePath) - .appendingPathExtension("json") - let outfileDirUrl = outFileUrl.deletingLastPathComponent() + private func ignoreDirectory(name: String) -> Bool { + let nameLowercased: String = name.lowercased() + return nameLowercased.contains("/.") + || nameLowercased.contains("/__") + || nameLowercased.contains("/tests/") + || nameLowercased.contains("/specs/") + || nameLowercased.contains("/test/") + || nameLowercased.contains("/spec/") + || ignorePathsFromPackageSwift.contains { nameLowercased.contains($0.lowercased()) } + } - if !FileManager.default.fileExists(atPath: outfileDirUrl.path) { - try FileManager.default.createDirectory( - atPath: outfileDirUrl.path, - withIntermediateDirectories: true, - attributes: nil - ) - } + private func parseFile(fileUrl: URL) { + do { + let relativeFilePath = fileUrl.relativePath(from: srcDir)! + let astJsonString = try SyntaxParser.parse( + srcDir: srcDir, + fileUrl: fileUrl, + relativeFilePath: relativeFilePath, + prettyPrint: prettyPrint + ) + let outFileUrl = + outputDir + .appendingPathComponent(relativeFilePath) + .appendingPathExtension("json") + let outfileDirUrl = outFileUrl.deletingLastPathComponent() - try astJsonString.write( - to: outFileUrl, - atomically: true, - encoding: String.Encoding.utf8 - ) - print("Generated AST for file: `\(fileUrl.path)`") - } catch { - print("Parsing failed for file: `\(fileUrl.path)` (\(error))") - } - } + if !FileManager.default.fileExists(atPath: outfileDirUrl.path) { + try FileManager.default.createDirectory( + atPath: outfileDirUrl.path, + withIntermediateDirectories: true, + attributes: nil + ) + } - private func iterateSwiftFiles(at url: URL) { - let queue = OperationQueue() - queue.name = "io.joern.swiftastgen.iteratequeue" - queue.qualityOfService = .userInitiated - queue.maxConcurrentOperationCount = availableProcessors + try astJsonString.write( + to: outFileUrl, + atomically: true, + encoding: String.Encoding.utf8 + ) + print("Generated AST for file: `\(fileUrl.path)`") + } catch { + print("Parsing failed for file: `\(fileUrl.path)` (\(error))") + } + } - if let enumerator = FileManager.default.enumerator( - at: url, - includingPropertiesForKeys: [.isRegularFileKey], - options: [.skipsHiddenFiles, .skipsPackageDescendants]) { - for case let fileURL as URL in enumerator { - let fileAttributes = try! fileURL.resourceValues(forKeys:[.isRegularFileKey]) - if fileAttributes.isRegularFile! && fileURL.pathExtension == "swift" { - let relativeFilePath = fileURL.relativePath(from: srcDir)! - if !ignoreDirectory(name: "/\(relativeFilePath)") { - queue.addOperation { - self.parseFile(fileUrl: fileURL) - } - } - } - } - } - queue.waitUntilAllOperationsAreFinished() - } + private func iterateSwiftFiles(at url: URL) { + let queue = OperationQueue() + queue.name = "io.joern.swiftastgen.iteratequeue" + queue.qualityOfService = .userInitiated + queue.maxConcurrentOperationCount = availableProcessors - public func generate() throws { - iterateSwiftFiles(at: srcDir) - } + if let enumerator = FileManager.default.enumerator( + at: url, + includingPropertiesForKeys: [.isRegularFileKey], + options: [.skipsHiddenFiles, .skipsPackageDescendants] + ) { + for case let fileURL as URL in enumerator { + let fileAttributes = try! fileURL.resourceValues(forKeys: [.isRegularFileKey]) + if fileAttributes.isRegularFile! && fileURL.pathExtension == "swift" { + let relativeFilePath = fileURL.relativePath(from: srcDir)! + if !ignoreDirectory(name: "/\(relativeFilePath)") { + queue.addOperation { + self.parseFile(fileUrl: fileURL) + } + } + } + } + } + queue.waitUntilAllOperationsAreFinished() + } + + public func generate() throws { + iterateSwiftFiles(at: srcDir) + } } diff --git a/Sources/SwiftAstGenLib/SyntaxParser.swift b/Sources/SwiftAstGenLib/SyntaxParser.swift index 517d65c..a1eb457 100644 --- a/Sources/SwiftAstGenLib/SyntaxParser.swift +++ b/Sources/SwiftAstGenLib/SyntaxParser.swift @@ -1,95 +1,98 @@ import Foundation -import SwiftParser + import SwiftOperators + +import SwiftParser + @_spi(RawSyntax) import SwiftSyntax extension SyntaxProtocol { - internal func toJson(converter: SourceLocationConverter) -> TreeNode { - var tokenKind = "" - var nodeType = "" - if let token = Syntax(self).as(TokenSyntax.self) { - tokenKind = String(describing: token.tokenKind) - } else { - nodeType = String(describing: syntaxNodeType) - } + internal func toJson(converter: SourceLocationConverter) -> TreeNode { + var tokenKind = "" + var nodeType = "" + if let token = Syntax(self).as(TokenSyntax.self) { + tokenKind = String(describing: token.tokenKind) + } else { + nodeType = String(describing: syntaxNodeType) + } - let sourceRange = sourceRange(converter: converter) - let rangeNode = Range( - startOffset: sourceRange.start.offset, - endOffset: sourceRange.end.offset, - startLine: sourceRange.start.line, - startColumn: sourceRange.start.column, - endLine: sourceRange.end.line, - endColumn: sourceRange.end.column - ) + let sourceRange = sourceRange(converter: converter) + let rangeNode = Range( + startOffset: sourceRange.start.offset, + endOffset: sourceRange.end.offset, + startLine: sourceRange.start.line, + startColumn: sourceRange.start.column, + endLine: sourceRange.end.line, + endColumn: sourceRange.end.column + ) - let allChildren = children(viewMode: .fixedUp) - var childrenNodes: [TreeNode] = [] + let allChildren = children(viewMode: .fixedUp) + var childrenNodes: [TreeNode] = [] - for (num, child) in allChildren.enumerated() { - var name = "" - var index = -1 - if let keyPath = child.keyPathInParent, let cname = childName(keyPath) { - name = cname - } else if self.kind.isSyntaxCollection { - index = num - } - let childNode = child.toJson(converter: converter) - childNode.name = name - childNode.index = index - childrenNodes.append(childNode) - } + for (num, child) in allChildren.enumerated() { + var name = "" + var index = -1 + if let keyPath = child.keyPathInParent, let cname = childName(keyPath) { + name = cname + } else if self.kind.isSyntaxCollection { + index = num + } + let childNode = child.toJson(converter: converter) + childNode.name = name + childNode.index = index + childrenNodes.append(childNode) + } - return TreeNode( - tokenKind: tokenKind, - nodeType: nodeType, - range: rangeNode, - children: childrenNodes) - } + return TreeNode( + tokenKind: tokenKind, + nodeType: nodeType, + range: rangeNode, + children: childrenNodes + ) + } } - struct SyntaxParser { - static func encode(_ s: String) -> String { - let data = s.data(using: .ascii, allowLossyConversion: true)! - return String(decoding: data, as: UTF8.self).replacingOccurrences(of: "\u{FFFD}", with: "?") - } + static func encode(_ s: String) -> String { + let data = s.data(using: .ascii, allowLossyConversion: true)! + return String(decoding: data, as: UTF8.self).replacingOccurrences(of: "\u{FFFD}", with: "?") + } - /// Counts the number of lines in a given string, handling all common line endings (\n, \r\n, \r) in a platform-independent way. - /// - Parameter text: The input string to count lines in. - /// - Returns: The number of lines in the string. - static func countLines(in text: String) -> Int { - // Use CharacterSet.newlines which matches \n, \r, \r\n, Unicode line/paragraph separators, etc. - // Split omitting empty subsequences to correctly handle trailing newlines. - let lines = text.split(omittingEmptySubsequences: false, whereSeparator: { $0.isNewline }) - return lines.count - } + /// Counts the number of lines in a given string, handling all common line endings (\n, \r\n, \r) in a platform-independent way. + /// - Parameter text: The input string to count lines in. + /// - Returns: The number of lines in the string. + static func countLines(in text: String) -> Int { + // Use CharacterSet.newlines which matches \n, \r, \r\n, Unicode line/paragraph separators, etc. + // Split omitting empty subsequences to correctly handle trailing newlines. + let lines = text.split(omittingEmptySubsequences: false, whereSeparator: { $0.isNewline }) + return lines.count + } - static func parse( - srcDir: URL, - fileUrl: URL, - relativeFilePath: String, - prettyPrint: Bool - ) throws -> String { - let code = try String(contentsOf: fileUrl) - let content = encode(code) - let loc = countLines(in: content) - let opPrecedence = OperatorTable.standardOperators - let ast = Parser.parse(source: content) - let folded = try opPrecedence.foldAll(ast) + static func parse( + srcDir: URL, + fileUrl: URL, + relativeFilePath: String, + prettyPrint: Bool + ) throws -> String { + let code = try String(contentsOf: fileUrl) + let content = encode(code) + let loc = countLines(in: content) + let opPrecedence = OperatorTable.standardOperators + let ast = Parser.parse(source: content) + let folded = try opPrecedence.foldAll(ast) - let locationConverter = SourceLocationConverter(fileName: fileUrl.path, tree: folded) - let treeNode = folded.toJson(converter: locationConverter) + let locationConverter = SourceLocationConverter(fileName: fileUrl.path, tree: folded) + let treeNode = folded.toJson(converter: locationConverter) - treeNode.projectFullPath = srcDir.standardized.resolvingSymlinksInPath().path - treeNode.fullFilePath = fileUrl.standardized.resolvingSymlinksInPath().path - treeNode.relativeFilePath = relativeFilePath - treeNode.content = content - treeNode.loc = loc + treeNode.projectFullPath = srcDir.standardized.resolvingSymlinksInPath().path + treeNode.fullFilePath = fileUrl.standardized.resolvingSymlinksInPath().path + treeNode.relativeFilePath = relativeFilePath + treeNode.content = content + treeNode.loc = loc - let encoder = JSONEncoder() - if prettyPrint { encoder.outputFormatting = .prettyPrinted } - return String(decoding: try encoder.encode(treeNode), as: UTF8.self) - } + let encoder = JSONEncoder() + if prettyPrint { encoder.outputFormatting = .prettyPrinted } + return String(decoding: try encoder.encode(treeNode), as: UTF8.self) + } } diff --git a/Sources/SwiftAstGenLib/TreeNode.swift b/Sources/SwiftAstGenLib/TreeNode.swift index c943e3e..cc3b46d 100644 --- a/Sources/SwiftAstGenLib/TreeNode.swift +++ b/Sources/SwiftAstGenLib/TreeNode.swift @@ -1,38 +1,37 @@ final class TreeNode: Codable { - var projectFullPath: String? - var relativeFilePath: String? - var fullFilePath: String? - var content: String? - var loc: Int? + var projectFullPath: String? + var relativeFilePath: String? + var fullFilePath: String? + var content: String? + var loc: Int? - var index: Int - var name: String - var tokenKind: String - var nodeType: String - var range: Range - var children: [TreeNode] + var index: Int + var name: String + var tokenKind: String + var nodeType: String + var range: Range + var children: [TreeNode] - init( - tokenKind: String, - nodeType: String, - range: Range, - children: [TreeNode] - ) { - self.index = -1 - self.name = "" - self.tokenKind = tokenKind - self.nodeType = nodeType - self.range = range - self.children = children - } + init( + tokenKind: String, + nodeType: String, + range: Range, + children: [TreeNode] + ) { + self.index = -1 + self.name = "" + self.tokenKind = tokenKind + self.nodeType = nodeType + self.range = range + self.children = children + } } - struct Range: Codable { - let startOffset: Int - let endOffset: Int - let startLine: Int - let startColumn: Int - let endLine: Int - let endColumn: Int + let startOffset: Int + let endOffset: Int + let startLine: Int + let startColumn: Int + let endLine: Int + let endColumn: Int } diff --git a/Sources/SwiftAstGenLib/TypeGenerator.swift b/Sources/SwiftAstGenLib/TypeGenerator.swift index 4721f69..093a769 100644 --- a/Sources/SwiftAstGenLib/TypeGenerator.swift +++ b/Sources/SwiftAstGenLib/TypeGenerator.swift @@ -2,45 +2,45 @@ import CodeGeneration struct TypeGenerator { - struct ReturnTypeAndCast { - let returnType: String - let cast: String - } + struct ReturnTypeAndCast { + let returnType: String + let cast: String + } - private static func type(for child: Child) -> String { - switch child.kind { - case .node(let kind): - return "\(kind.syntaxType)" - case .nodeChoices(let choices): - let choicesDescriptions = choices.map { type(for: $0) } - return "\(choicesDescriptions.joined(separator: " | "))" - case .collection(let kind, _, _, _): - return "\(kind.syntaxType)" - case .token(_, _, _): - return "SwiftToken" - } - } + private static func type(for child: Child) -> String { + switch child.kind { + case .node(let kind): + return "\(kind.syntaxType)" + case .nodeChoices(let choices): + let choicesDescriptions = choices.map { type(for: $0) } + return "\(choicesDescriptions.joined(separator: " | "))" + case .collection(let kind, _, _, _): + return "\(kind.syntaxType)" + case .token(_, _, _): + return "SwiftToken" + } + } - static func returnTypeAndCast(for child: Child) -> ReturnTypeAndCast { - let childType = type(for: child) - let isOptional = child.isOptional - let returnType = isOptional ? "Option[\(childType)]" : "\(childType)" - let cast = - isOptional ? ".map(_.asInstanceOf[\(childType)])" : ".head.asInstanceOf[\(childType)]" - return ReturnTypeAndCast(returnType: returnType, cast: cast) - } + static func returnTypeAndCast(for child: Child) -> ReturnTypeAndCast { + let childType = type(for: child) + let isOptional = child.isOptional + let returnType = isOptional ? "Option[\(childType)]" : "\(childType)" + let cast = + isOptional ? ".map(_.asInstanceOf[\(childType)])" : ".head.asInstanceOf[\(childType)]" + return ReturnTypeAndCast(returnType: returnType, cast: cast) + } - static func returnTypeAndCast(for collection: CollectionNode) -> ReturnTypeAndCast { - let collectionType: String - if let onlyElement = collection.elementChoices.only { - collectionType = "\(onlyElement.syntaxType)" - } else { - collectionType = - "\(collection.elementChoices.map { "\($0.syntaxType)" }.joined(separator: " | "))" - } - let returnType = "Seq[\(collectionType)]" - let cast = ".map(_.asInstanceOf[\(collectionType)])" - return ReturnTypeAndCast(returnType: returnType, cast: cast) - } + static func returnTypeAndCast(for collection: CollectionNode) -> ReturnTypeAndCast { + let collectionType: String + if let onlyElement = collection.elementChoices.only { + collectionType = "\(onlyElement.syntaxType)" + } else { + collectionType = + "\(collection.elementChoices.map { "\($0.syntaxType)" }.joined(separator: " | "))" + } + let returnType = "Seq[\(collectionType)]" + let cast = ".map(_.asInstanceOf[\(collectionType)])" + return ReturnTypeAndCast(returnType: returnType, cast: cast) + } } diff --git a/Tests/SwiftAstGenTests/PackageTestTargetParserTests.swift b/Tests/SwiftAstGenTests/PackageTestTargetParserTests.swift new file mode 100644 index 0000000..4664552 --- /dev/null +++ b/Tests/SwiftAstGenTests/PackageTestTargetParserTests.swift @@ -0,0 +1,213 @@ +import Foundation + +import XCTest + +@testable import class SwiftAstGenLib.PackageTestTargetParser + +final class PackageTestTargetParserTests: XCTestCase { + + static var allTests = [ + ("testSingleTestTarget", testSingleTestTarget), + ("testMultipleTestTargets", testMultipleTestTargets), + ("testTestTargetWithExplicitPath", testTestTargetWithExplicitPath), + ("testMixedTestTargets", testMixedTestTargets), + ("testNoTestTargets", testNoTestTargets), + ("testMissingPackageSwift", testMissingPackageSwift), + ] + + private func createTemporaryDirectory() -> URL { + let tempDir = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("PackageTestTargetParserTests-\(UUID().uuidString)", isDirectory: true) + try! FileManager.default.createDirectory(atPath: tempDir.path, withIntermediateDirectories: true) + return tempDir + } + + private func cleanup(directory: URL) { + try? FileManager.default.removeItem(at: directory) + } + + private func createPackageSwift(in directory: URL, content: String) { + let packageSwiftUrl = directory.appendingPathComponent("Package.swift") + try! content.write(to: packageSwiftUrl, atomically: true, encoding: .utf8) + } + + func testSingleTestTarget() throws { + let tempDir = createTemporaryDirectory() + defer { cleanup(directory: tempDir) } + + let packageContent = """ + // swift-tools-version: 5.10 + import PackageDescription + + let package = Package( + name: "TestPackage", + targets: [ + .target(name: "TestPackage"), + .testTarget( + name: "TestPackageTests", + dependencies: ["TestPackage"] + ), + ] + ) + """ + + createPackageSwift(in: tempDir, content: packageContent) + + let parser = PackageTestTargetParser(srcDir: tempDir) + let testTargetPaths = parser.getTestTargetPaths() + + XCTAssertEqual(testTargetPaths.count, 1) + XCTAssertEqual(testTargetPaths[0], "Tests/TestPackageTests") + } + + func testMultipleTestTargets() throws { + let tempDir = createTemporaryDirectory() + defer { cleanup(directory: tempDir) } + + let packageContent = """ + // swift-tools-version: 5.10 + import PackageDescription + + let package = Package( + name: "TestPackage", + targets: [ + .target(name: "TestPackage"), + .testTarget( + name: "TestPackageTests", + dependencies: ["TestPackage"] + ), + .testTarget( + name: "IntegrationTests", + dependencies: ["TestPackage"] + ), + .testTarget( + name: "PerformanceTests", + dependencies: ["TestPackage"] + ), + ] + ) + """ + + createPackageSwift(in: tempDir, content: packageContent) + + let parser = PackageTestTargetParser(srcDir: tempDir) + let testTargetPaths = parser.getTestTargetPaths() + + XCTAssertEqual(testTargetPaths.count, 3) + XCTAssertTrue(testTargetPaths.contains("Tests/TestPackageTests")) + XCTAssertTrue(testTargetPaths.contains("Tests/IntegrationTests")) + XCTAssertTrue(testTargetPaths.contains("Tests/PerformanceTests")) + } + + func testTestTargetWithExplicitPath() throws { + let tempDir = createTemporaryDirectory() + defer { cleanup(directory: tempDir) } + + let packageContent = """ + // swift-tools-version: 5.10 + import PackageDescription + + let package = Package( + name: "TestPackage", + targets: [ + .target(name: "TestPackage"), + .testTarget( + name: "TestPackageTests", + dependencies: ["TestPackage"], + path: "CustomTests/Unit" + ), + ] + ) + """ + + createPackageSwift(in: tempDir, content: packageContent) + + let parser = PackageTestTargetParser(srcDir: tempDir) + let testTargetPaths = parser.getTestTargetPaths() + + XCTAssertEqual(testTargetPaths.count, 1) + XCTAssertEqual(testTargetPaths[0], "CustomTests/Unit") + } + + func testMixedTestTargets() throws { + let tempDir = createTemporaryDirectory() + defer { cleanup(directory: tempDir) } + + let packageContent = """ + // swift-tools-version: 5.10 + import PackageDescription + + let package = Package( + name: "TestPackage", + targets: [ + .target(name: "TestPackage"), + .testTarget( + name: "TestPackageTests", + dependencies: ["TestPackage"] + ), + .testTarget( + name: "CustomTests", + dependencies: ["TestPackage"], + path: "MyCustomPath/Tests" + ), + .target(name: "AnotherTarget"), + .testTarget( + name: "AnotherTargetTests", + dependencies: ["AnotherTarget"] + ), + ] + ) + """ + + createPackageSwift(in: tempDir, content: packageContent) + + let parser = PackageTestTargetParser(srcDir: tempDir) + let testTargetPaths = parser.getTestTargetPaths() + + XCTAssertEqual(testTargetPaths.count, 3) + XCTAssertTrue(testTargetPaths.contains("Tests/TestPackageTests")) + XCTAssertTrue(testTargetPaths.contains("MyCustomPath/Tests")) + XCTAssertTrue(testTargetPaths.contains("Tests/AnotherTargetTests")) + } + + func testNoTestTargets() throws { + let tempDir = createTemporaryDirectory() + defer { cleanup(directory: tempDir) } + + let packageContent = """ + // swift-tools-version: 5.10 + import PackageDescription + + let package = Package( + name: "TestPackage", + targets: [ + .target(name: "TestPackage"), + .target(name: "AnotherTarget"), + .executableTarget( + name: "MyExecutable", + dependencies: ["TestPackage"] + ), + ] + ) + """ + + createPackageSwift(in: tempDir, content: packageContent) + + let parser = PackageTestTargetParser(srcDir: tempDir) + let testTargetPaths = parser.getTestTargetPaths() + + XCTAssertEqual(testTargetPaths.count, 0) + } + + func testMissingPackageSwift() throws { + let tempDir = createTemporaryDirectory() + defer { cleanup(directory: tempDir) } + + // Don't create Package.swift + + let parser = PackageTestTargetParser(srcDir: tempDir) + let testTargetPaths = parser.getTestTargetPaths() + + XCTAssertEqual(testTargetPaths.count, 0) + } +} diff --git a/Tests/SwiftAstGenTests/ScalaAstGenTests.swift b/Tests/SwiftAstGenTests/ScalaAstGenTests.swift index b4bd021..75040c7 100644 --- a/Tests/SwiftAstGenTests/ScalaAstGenTests.swift +++ b/Tests/SwiftAstGenTests/ScalaAstGenTests.swift @@ -4,19 +4,19 @@ import XCTest final class ScalaAstGenTests: XCTestCase { - static var allTests = [ - ("testScalaSourceFileOutput", testScalaSourceFileOutput) - ] + static var allTests = [ + ("testScalaSourceFileOutput", testScalaSourceFileOutput) + ] - func testScalaSourceFileOutput() throws { - let scalaOutFileUrl = URL(fileURLWithPath: "./SwiftNodeSyntax.scala") - try ScalaAstGenerator().generate() - XCTAssertTrue(FileManager.default.fileExists(atPath: scalaOutFileUrl.path)) - if let content = try? String(contentsOf: scalaOutFileUrl, encoding: .utf8) { - XCTAssertTrue(content.contains("object SwiftNodeSyntax {")) - } else { - XCTFail("Could not create the SwiftNodeSyntax.scala file containing the Scala Swift AST.") - } - } + func testScalaSourceFileOutput() throws { + let scalaOutFileUrl = URL(fileURLWithPath: "./SwiftNodeSyntax.scala") + try ScalaAstGenerator().generate() + XCTAssertTrue(FileManager.default.fileExists(atPath: scalaOutFileUrl.path)) + if let content = try? String(contentsOf: scalaOutFileUrl, encoding: .utf8) { + XCTAssertTrue(content.contains("object SwiftNodeSyntax {")) + } else { + XCTFail("Could not create the SwiftNodeSyntax.scala file containing the Scala Swift AST.") + } + } } diff --git a/Tests/SwiftAstGenTests/SwiftAstGenTests.swift b/Tests/SwiftAstGenTests/SwiftAstGenTests.swift index a9e1f6d..f527b56 100644 --- a/Tests/SwiftAstGenTests/SwiftAstGenTests.swift +++ b/Tests/SwiftAstGenTests/SwiftAstGenTests.swift @@ -4,84 +4,240 @@ import XCTest final class SwiftAstGenTests: XCTestCase, TestUtils { - static var allTests = [ - ("testJsonSourceFileSyntax", testJsonSourceFileSyntax), - ("testJsonFilePaths", testJsonFilePaths), - ("testJsonLoc", testJsonLoc), - ] + static var allTests = [ + ("testJsonSourceFileSyntax", testJsonSourceFileSyntax), + ("testJsonFilePaths", testJsonFilePaths), + ("testJsonLoc", testJsonLoc), + ("testIgnoresTestTargetPathsFromPackageSwift", testIgnoresTestTargetPathsFromPackageSwift), + ("testIgnoresMultipleTestTargetPaths", testIgnoresMultipleTestTargetPaths), + ("testIgnoresCustomTestTargetPath", testIgnoresCustomTestTargetPath), + ] - func testJsonSourceFileSyntax() throws { - try withCode( - code: """ - print("Hello World!") - """ - ) { srcDir, outputDir, jsonFile in + func testJsonSourceFileSyntax() throws { + try withCode( + code: """ + print("Hello World!") + """ + ) { srcDir, outputDir, jsonFile in - try SwiftAstGenerator( - srcDir: srcDir, - outputDir: outputDir, - prettyPrint: false - ).generate() + try SwiftAstGenerator( + srcDir: srcDir, + outputDir: outputDir, + prettyPrint: false + ).generate() - XCTAssertTrue(FileManager.default.fileExists(atPath: jsonFile.path)) - if let treeNode = loadJson(file: jsonFile) { - XCTAssertEqual(treeNode.nodeType, "SourceFileSyntax") - } else { - XCTFail("Could not create the JSON containing the Swift AST.") - } - } - } + XCTAssertTrue(FileManager.default.fileExists(atPath: jsonFile.path)) + if let treeNode = loadJson(file: jsonFile) { + XCTAssertEqual(treeNode.nodeType, "SourceFileSyntax") + } else { + XCTFail("Could not create the JSON containing the Swift AST.") + } + } + } - func testJsonFilePaths() throws { - try withCode( - code: """ - print("Hello World!") - """ - ) { srcDir, outputDir, jsonFile in + func testJsonFilePaths() throws { + try withCode( + code: """ + print("Hello World!") + """ + ) { srcDir, outputDir, jsonFile in - try SwiftAstGenerator( - srcDir: srcDir, - outputDir: outputDir, - prettyPrint: false - ).generate() + try SwiftAstGenerator( + srcDir: srcDir, + outputDir: outputDir, + prettyPrint: false + ).generate() - XCTAssertTrue(FileManager.default.fileExists(atPath: jsonFile.path)) - if let treeNode = loadJson(file: jsonFile) { - let projectFullPath = treeNode.projectFullPath! - let relativeFilePath = treeNode.relativeFilePath! - let fullFilePath = treeNode.fullFilePath! + XCTAssertTrue(FileManager.default.fileExists(atPath: jsonFile.path)) + if let treeNode = loadJson(file: jsonFile) { + let projectFullPath = treeNode.projectFullPath! + let relativeFilePath = treeNode.relativeFilePath! + let fullFilePath = treeNode.fullFilePath! - XCTAssertEqual(relativeFilePath, "source.swift") - XCTAssertEqual(fullFilePath, "\(projectFullPath)/\(relativeFilePath)") - } else { - XCTFail("Could not create the JSON containing the Swift AST.") - } - } - } + XCTAssertEqual(relativeFilePath, "source.swift") + XCTAssertEqual(fullFilePath, "\(projectFullPath)/\(relativeFilePath)") + } else { + XCTFail("Could not create the JSON containing the Swift AST.") + } + } + } - func testJsonLoc() throws { - try withCode( - code: """ - print("1") - print("2") - print("3") - """ - ) { srcDir, outputDir, jsonFile in + func testJsonLoc() throws { + try withCode( + code: """ + print("1") + print("2") + print("3") + """ + ) { srcDir, outputDir, jsonFile in - try SwiftAstGenerator( - srcDir: srcDir, - outputDir: outputDir, - prettyPrint: false - ).generate() + try SwiftAstGenerator( + srcDir: srcDir, + outputDir: outputDir, + prettyPrint: false + ).generate() - XCTAssertTrue(FileManager.default.fileExists(atPath: jsonFile.path)) - if let treeNode = loadJson(file: jsonFile) { - let loc = treeNode.loc! - XCTAssertEqual(loc, 3) - } else { - XCTFail("Could not create the JSON containing the Swift AST.") - } - } - } + XCTAssertTrue(FileManager.default.fileExists(atPath: jsonFile.path)) + if let treeNode = loadJson(file: jsonFile) { + let loc = treeNode.loc! + XCTAssertEqual(loc, 3) + } else { + XCTFail("Could not create the JSON containing the Swift AST.") + } + } + } + + func testIgnoresTestTargetPathsFromPackageSwift() throws { + let tempDir = createTemporaryDirectory() + defer { cleanup(directory: tempDir) } + + // Create Package.swift with a test target + let packageContent = """ + // swift-tools-version: 5.10 + import PackageDescription + + let package = Package( + name: "TestProject", + targets: [ + .target(name: "TestProject"), + .testTarget( + name: "TestProjectTests", + dependencies: ["TestProject"] + ), + ] + ) + """ + createFile(at: tempDir, path: "Package.swift", content: packageContent) + + // Create a Swift file in the main source + let sourceCode = "print(\"Main source\")" + createFile(at: tempDir, path: "Sources/main.swift", content: sourceCode) + + // Create a Swift file in the test target path + let testCode = "print(\"Test code\")" + createFile(at: tempDir, path: "Tests/TestProjectTests/TestFile.swift", content: testCode) + + let outputDir = tempDir.appendingPathComponent("output") + + try SwiftAstGenerator( + srcDir: tempDir, + outputDir: outputDir, + prettyPrint: false + ).generate() + + // Main source file should be processed + let mainJsonPath = outputDir.appendingPathComponent("Sources/main.swift.json") + XCTAssertTrue( + FileManager.default.fileExists(atPath: mainJsonPath.path), + "Main source file should be processed" + ) + + // Test file should be ignored + let testJsonPath = outputDir.appendingPathComponent("Tests/TestProjectTests/TestFile.swift.json") + XCTAssertFalse( + FileManager.default.fileExists(atPath: testJsonPath.path), + "Test target file should be ignored" + ) + } + + func testIgnoresMultipleTestTargetPaths() throws { + let tempDir = createTemporaryDirectory() + defer { cleanup(directory: tempDir) } + + // Create Package.swift with multiple test targets + let packageContent = """ + // swift-tools-version: 5.10 + import PackageDescription + + let package = Package( + name: "TestProject", + targets: [ + .target(name: "TestProject"), + .testTarget(name: "UnitTests", dependencies: ["TestProject"]), + .testTarget(name: "IntegrationTests", dependencies: ["TestProject"]), + ] + ) + """ + createFile(at: tempDir, path: "Package.swift", content: packageContent) + + // Create source file + createFile(at: tempDir, path: "Sources/main.swift", content: "print(\"main\")") + + // Create test files in different test targets + createFile(at: tempDir, path: "Tests/UnitTests/UnitTest.swift", content: "print(\"unit\")") + createFile(at: tempDir, path: "Tests/IntegrationTests/IntegrationTest.swift", content: "print(\"integration\")") + + let outputDir = tempDir.appendingPathComponent("output") + + try SwiftAstGenerator( + srcDir: tempDir, + outputDir: outputDir, + prettyPrint: false + ).generate() + + // Main source should be processed + XCTAssertTrue( + FileManager.default.fileExists(atPath: outputDir.appendingPathComponent("Sources/main.swift.json").path) + ) + + // Both test files should be ignored + XCTAssertFalse( + FileManager.default.fileExists(atPath: outputDir.appendingPathComponent("Tests/UnitTests/UnitTest.swift.json").path), + "UnitTests should be ignored" + ) + XCTAssertFalse( + FileManager.default.fileExists(atPath: outputDir.appendingPathComponent("Tests/IntegrationTests/IntegrationTest.swift.json").path), + "IntegrationTests should be ignored" + ) + } + + func testIgnoresCustomTestTargetPath() throws { + let tempDir = createTemporaryDirectory() + defer { cleanup(directory: tempDir) } + + // Create Package.swift with custom test path + let packageContent = """ + // swift-tools-version: 5.10 + import PackageDescription + + let package = Package( + name: "TestProject", + targets: [ + .target(name: "TestProject"), + .testTarget( + name: "MyTests", + dependencies: ["TestProject"], + path: "CustomTestPath" + ), + ] + ) + """ + createFile(at: tempDir, path: "Package.swift", content: packageContent) + + // Create source file + createFile(at: tempDir, path: "Sources/main.swift", content: "print(\"main\")") + + // Create test file in custom path + createFile(at: tempDir, path: "CustomTestPath/MyTest.swift", content: "print(\"test\")") + + let outputDir = tempDir.appendingPathComponent("output") + + try SwiftAstGenerator( + srcDir: tempDir, + outputDir: outputDir, + prettyPrint: false + ).generate() + + // Main source should be processed + XCTAssertTrue( + FileManager.default.fileExists(atPath: outputDir.appendingPathComponent("Sources/main.swift.json").path) + ) + + // Custom test path should be ignored + XCTAssertFalse( + FileManager.default.fileExists(atPath: outputDir.appendingPathComponent("CustomTestPath/MyTest.swift.json").path), + "Custom test path should be ignored" + ) + } } diff --git a/Tests/SwiftAstGenTests/TestUtils.swift b/Tests/SwiftAstGenTests/TestUtils.swift index 4c769fb..3f3279b 100644 --- a/Tests/SwiftAstGenTests/TestUtils.swift +++ b/Tests/SwiftAstGenTests/TestUtils.swift @@ -3,56 +3,82 @@ import Foundation @testable import class SwiftAstGenLib.TreeNode protocol TestUtils { - func loadJson(file: URL) -> TreeNode? - func withCode(code: String, testFunction: (URL, URL, URL) throws -> Void) throws + func loadJson(file: URL) -> TreeNode? + func withCode(code: String, testFunction: (URL, URL, URL) throws -> Void) throws } - extension TestUtils { - private func createUniqueName() -> String { - return "SwiftAstGenTests\(UUID().uuidString)" - } - - private func temporaryFileURL(fileName: String) -> URL? { - return URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true).appendingPathComponent( - fileName, isDirectory: true) - } - - func loadJson(file: URL) -> TreeNode? { - let decoder = JSONDecoder() - guard - let content = try? String(contentsOf: file, encoding: .utf8), - let treeNode = try? decoder.decode(TreeNode.self, from: content.data(using: .utf8)!) - else { - return nil - } - return treeNode - } - - func withCode(code: String, testFunction: (URL, URL, URL) throws -> Void) throws { - let srcTmpDir = temporaryFileURL(fileName: createUniqueName())! - let outTmpDir = srcTmpDir.appendingPathComponent("out", isDirectory: true) - let srcFile = srcTmpDir.appendingPathComponent("source.swift") - let jsonFile = outTmpDir.appendingPathComponent("source.swift.json") - - try FileManager.default.createDirectory( - atPath: srcTmpDir.path, - withIntermediateDirectories: true, - attributes: nil - ) - _ = FileManager.default.createFile( - atPath: srcFile.path, - contents: nil, - attributes: nil - ) - try code.write( - to: srcFile, - atomically: true, - encoding: String.Encoding.utf8 - ) - - try testFunction(srcTmpDir, outTmpDir, jsonFile) - try FileManager.default.removeItem(at: srcTmpDir) - } + private func createUniqueName() -> String { + return "SwiftAstGenTests\(UUID().uuidString)" + } + + func createTemporaryDirectory() -> URL { + let tempDir = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent(createUniqueName(), isDirectory: true) + try! FileManager.default.createDirectory(atPath: tempDir.path, withIntermediateDirectories: true) + return tempDir + } + + func cleanup(directory: URL) { + try? FileManager.default.removeItem(at: directory) + } + + func createFile(at baseDir: URL, path: String, content: String) { + let fileUrl = baseDir.appendingPathComponent(path) + let dirUrl = fileUrl.deletingLastPathComponent() + + if !FileManager.default.fileExists(atPath: dirUrl.path) { + try! FileManager.default.createDirectory( + atPath: dirUrl.path, + withIntermediateDirectories: true + ) + } + + try! content.write(to: fileUrl, atomically: true, encoding: .utf8) + } + + private func temporaryFileURL(fileName: String) -> URL? { + return URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true).appendingPathComponent( + fileName, + isDirectory: true + ) + } + + func loadJson(file: URL) -> TreeNode? { + let decoder = JSONDecoder() + guard + let content = try? String(contentsOf: file, encoding: .utf8), + let treeNode = try? decoder.decode(TreeNode.self, from: content.data(using: .utf8)!) + else { + return nil + } + return treeNode + } + + func withCode(code: String, testFunction: (URL, URL, URL) throws -> Void) throws { + let srcTmpDir = temporaryFileURL(fileName: createUniqueName())! + let outTmpDir = srcTmpDir.appendingPathComponent("out", isDirectory: true) + let srcFile = srcTmpDir.appendingPathComponent("source.swift") + let jsonFile = outTmpDir.appendingPathComponent("source.swift.json") + + try FileManager.default.createDirectory( + atPath: srcTmpDir.path, + withIntermediateDirectories: true, + attributes: nil + ) + _ = FileManager.default.createFile( + atPath: srcFile.path, + contents: nil, + attributes: nil + ) + try code.write( + to: srcFile, + atomically: true, + encoding: String.Encoding.utf8 + ) + + try testFunction(srcTmpDir, outTmpDir, jsonFile) + try FileManager.default.removeItem(at: srcTmpDir) + } }