diff --git a/Sources/Macros/Macros.swift b/Sources/Macros/Macros.swift
index 2e2f81f..c85a7d5 100644
--- a/Sources/Macros/Macros.swift
+++ b/Sources/Macros/Macros.swift
@@ -289,11 +289,95 @@ public struct MustOr: CodeItemMacro {
}
}
+public struct CliTestDeclarationMacro: PeerMacro, Sendable {
+
+ private static func doc_shrink(_ from: String) -> String {
+ return from.replacing(Regex(#/^.*\/\/\/[\s]+/#), with: "")
+ }
+
+ public static func expansion(
+ of node: AttributeSyntax,
+ providingPeersOf declaration: some DeclSyntaxProtocol,
+ in context: some MacroExpansionContext
+ ) throws -> [DeclSyntax] {
+
+ let cli_test_expected_output = node.leadingTrivia.filter({ $0.isComment }).map({
+ doc_shrink("\($0)")
+ }).joined(separator: "\\n")
+
+ let cli_test_driver_thunk_name = context.makeUniqueName(
+ declaration.cast(FunctionDeclSyntax.self).name.text + "_thunk_")
+ let cli_test_driver_thunk: DeclSyntax = """
+ @Sendable private func \(cli_test_driver_thunk_name)() async throws {
+ let expected = "\(raw: cli_test_expected_output)"
+
+ _ = unsafe try await Testing.__requiringUnsafe(
+ Testing.__requiringTry(
+ Testing.__requiringAwait(swiftCliTestRunner(\(declaration.cast(FunctionDeclSyntax.self).name), expected))))
+ }
+ """
+
+ let cli_test_driver_generator_name = context.makeUniqueName(
+ declaration.cast(FunctionDeclSyntax.self).name.text + "_generator_")
+ let cli_test_driver_generator: DeclSyntax = """
+ @Sendable private func \(cli_test_driver_generator_name)() async -> Testing.Test {
+ return .__function(
+ named: "xxxxxxxx()",
+ in: nil,
+ xcTestCompatibleSelector: Testing.__xcTestCompatibleSelector("xxxxxx:"),
+ traits: [],
+ sourceLocation: Testing.SourceLocation(
+ fileID: "Tests/CliTests/Cli.swift",
+ filePath: "/Users/hawkinsw/code/p4ce/Tests/p4rseTests/CliTests/Cli.swift", line: 359,
+ column: 2),
+ parameters: [],
+ testFunction: \(cli_test_driver_thunk_name)
+ )
+ }
+ """
+
+ let cli_test_driver_accessor_name = context.makeUniqueName(
+ declaration.cast(FunctionDeclSyntax.self).name.text + "_accessor_")
+ let cli_test_driver_accessor: DeclSyntax = """
+ private nonisolated let \(cli_test_driver_accessor_name): Accessor = { outValue, type, _, _ in
+
+ Testing.Test.__store(\(cli_test_driver_generator_name), into: outValue, asTypeAt: type)
+ }
+ """
+
+ let cli_test_driver_content_record_name = context.makeUniqueName(
+ declaration.cast(FunctionDeclSyntax.self).name.text + "_cr_")
+ let cli_test_driver_cr: DeclSyntax = """
+ private nonisolated let \(cli_test_driver_content_record_name): TestContentRecord = (
+ 0x7465_7374, /* indicate a test */
+ 0,
+ unsafe \(cli_test_driver_accessor_name),
+ 0,
+ 0
+ )
+ """
+
+ let cli_test_driver_content_container_name = context.makeUniqueName(
+ declaration.cast(FunctionDeclSyntax.self).name.text + "__🟡$_container_")
+ let cli_test_driver_container: DeclSyntax = """
+ struct \(cli_test_driver_content_container_name): Testing.__TestContentRecordContainer {
+ nonisolated static var __testContentRecord: TestContentRecord {
+ unsafe \(cli_test_driver_content_record_name)
+ }
+ }
+ """
+ return [
+ cli_test_driver_thunk, cli_test_driver_generator, cli_test_driver_accessor,
+ cli_test_driver_cr, cli_test_driver_container,
+ ]
+ }
+}
+
@main
struct P4Macros: CompilerPlugin {
var providingMacros: [Macro.Type] = [
RequireResult.self, RequireErrorResult.self, UseOkResult.self, UseErrorResult.self,
RequireNodeType.self, SkipUnlessNodeType.self, SkipUnlessNodesTypes.self, RequireNodesType.self,
- MustOr.self,
+ MustOr.self, CliTestDeclarationMacro.self,
]
}
diff --git a/Tests/Support.swift b/Tests/Support.swift
new file mode 100644
index 0000000..a2f827c
--- /dev/null
+++ b/Tests/Support.swift
@@ -0,0 +1,104 @@
+// p4rse, Copyright 2026, Will Hawkins
+//
+// This file is part of p4rse.
+//
+// This file is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+import Foundation
+import Testing
+
+typealias Accessor =
+ @convention(c) (
+ _ outValue: UnsafeMutableRawPointer,
+ _ type: UnsafeRawPointer,
+ _ hint: UnsafeRawPointer?,
+ _ reserved: UInt
+ ) -> CBool
+
+typealias TestContentRecord = (
+ kind: UInt32,
+ reserved1: UInt32,
+ accessor: Accessor?,
+ context: UInt,
+ reserved2: UInt
+)
+
+func findPathEnv() -> [URL]? {
+ for (key, value) in ProcessInfo().environment {
+ if key == "PATH" {
+ return value.split(separator: ":").map { URL(filePath: "\($0)") }
+ }
+ }
+
+ return .none
+}
+
+func findInPath(_ what: String) -> URL? {
+ guard let bin_paths = findPathEnv() else {
+ return .none
+ }
+
+ let fm = FileManager()
+ for bin_path in bin_paths {
+ let sought = bin_path.appending(path: what)
+ if fm.fileExists(atPath: sought.path) {
+ return sought
+ }
+ }
+
+ return .none
+}
+
+func swiftPath() -> URL? {
+ return findInPath("swift")
+}
+
+func swiftRun(withArgs args: [String]) throws -> String? {
+ let path = swiftPath()!
+ let child = Process()
+ let so = Pipe()
+ let se = Pipe()
+
+ child.standardOutput = so
+ child.executableURL = path
+ child.arguments =
+ [
+ "run",
+ "--ignore-lock" /* --ignore-lock needs to be early because it is a "location option". */,
+ "--skip-build",
+ ] + args
+
+ try! child.run()
+
+ let output =
+ switch child.standardOutput {
+ case let so as Pipe:
+ String(data: try! so.fileHandleForReading.readToEnd()!, encoding: String.Encoding.ascii)
+ default: Optional.none
+ }
+ return output
+}
+
+func swiftCliTestRunner(_ arg_gen: () -> [String], _ expected: String) async throws {
+ let args = arg_gen()
+
+ let run_output = try! swiftRun(withArgs: args)
+
+ #expect(run_output == expected)
+}
+
+
+
+@attached(peer) public macro CliTest() =
+ #externalMacro(module: "Macros", type: "CliTestDeclarationMacro")
\ No newline at end of file
diff --git a/Tests/p4rseTests/CliTests/Cli.swift b/Tests/p4rseTests/CliTests/Cli.swift
new file mode 100644
index 0000000..3ac0b95
--- /dev/null
+++ b/Tests/p4rseTests/CliTests/Cli.swift
@@ -0,0 +1,26 @@
+// p4rse, Copyright 2026, Will Hawkins
+//
+// This file is part of p4rse.
+//
+// This file is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+import Foundation
+import Testing
+
+/// Success
+///
+@CliTest()
+func simple_cli_test() -> [String] {
+ return ["p4ce", "--plain", "compile", "simple.p4", "-I", "TestData/Sources/"]
+}
\ No newline at end of file