Skip to content

Commit 3ab57e2

Browse files
committed
feat: add arbitrary-length #magic macro using InlineArray
- Add new #magic macro supporting ASCII strings of any length - Extends beyond #magicNumber's 2/4/8 byte limitation - Uses InlineArray<N, UInt8> for compile-time optimization - Add _loadAndCheckInlineArrayBytes helper function - Add InlineArray Equatable conformance for UInt8 elements - Include comprehensive tests: macro expansion + end-to-end runtime - Maintains backward compatibility with existing #magicNumber
1 parent a09c22b commit 3ab57e2

File tree

7 files changed

+288
-0
lines changed

7 files changed

+288
-0
lines changed

Sources/BinaryParsing/Macros/Macros.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,7 @@
1212
@freestanding(expression)
1313
public macro magicNumber(_ code: String, parsing input: inout ParserSpan) =
1414
#externalMacro(module: "BinaryParsingMacros", type: "MagicNumberStringMacro")
15+
16+
@freestanding(expression)
17+
public macro magic(_ code: String, parsing input: inout ParserSpan) =
18+
#externalMacro(module: "BinaryParsingMacros", type: "MagicMacro")

Sources/BinaryParsing/Macros/MagicNumber.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,17 @@ public func _loadAndCheckDirectBytesByteOrder<
4141
location: input.startPosition)
4242
}
4343
}
44+
45+
@available(macOS 26, iOS 26, watchOS 26, tvOS 26, visionOS 26, *)
46+
@_lifetime(&input)
47+
@inlinable
48+
public func _loadAndCheckInlineArrayBytes<let N: Int>(
49+
parsing input: inout ParserSpan,
50+
expectedBytes: InlineArray<N, UInt8>
51+
) throws(ParsingError) {
52+
let parsedBytes = try? InlineArray<N, UInt8>(parsing: &input)
53+
guard parsedBytes == expectedBytes else {
54+
throw ParsingError(
55+
status: .invalidValue, location: input.startPosition)
56+
}
57+
}

Sources/BinaryParsing/Parsers/InlineArray.swift

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,21 @@ extension InlineArray where Element == UInt8 {
2727
}
2828
}
2929

30+
@available(macOS 26, iOS 26, watchOS 26, tvOS 26, visionOS 26, *)
31+
extension InlineArray: @retroactive Equatable where Element == UInt8 {
32+
@inlinable
33+
public static func == (
34+
lhs: InlineArray<count, UInt8>, rhs: InlineArray<count, UInt8>
35+
) -> Bool {
36+
for i in 0..<count {
37+
if lhs[i] != rhs[i] {
38+
return false
39+
}
40+
}
41+
return true
42+
}
43+
}
44+
3045
@available(macOS 26, iOS 26, watchOS 26, tvOS 26, visionOS 26, *)
3146
extension InlineArray where Element: ~Copyable {
3247
/// Creates a new array by parsing the specified number of elements from the given
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift Binary Parsing open source project
4+
//
5+
// Copyright (c) 2025 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See https://swift.org/LICENSE.txt for license information
9+
//
10+
//===----------------------------------------------------------------------===//
11+
12+
import SwiftDiagnostics
13+
import SwiftSyntax
14+
import SwiftSyntaxBuilder
15+
import SwiftSyntaxMacros
16+
17+
public struct MagicMacro: ExpressionMacro {
18+
public static func expansion(
19+
of node: some FreestandingMacroExpansionSyntax,
20+
in context: some MacroExpansionContext
21+
) -> ExprSyntax {
22+
guard let argument = node.arguments.first?.expression,
23+
let stringLiteral = argument.as(StringLiteralExprSyntax.self)
24+
else {
25+
context.diagnose(
26+
.init(
27+
node: node,
28+
message: MacroExpansionErrorMessage(
29+
"Magic bytes must be expressed as a string literal.")))
30+
return ""
31+
}
32+
33+
// Handle both single-segment and multi-segment strings (for escape sequences)
34+
var string = ""
35+
for segment in stringLiteral.segments {
36+
switch segment {
37+
case .stringSegment(let literalSegment):
38+
string += literalSegment.content.text
39+
case .expressionSegment(_):
40+
context.diagnose(
41+
.init(
42+
node: node,
43+
message: MacroExpansionErrorMessage(
44+
"String interpolation not supported in magic bytes.")))
45+
return ""
46+
@unknown default:
47+
context.diagnose(
48+
.init(
49+
node: node,
50+
message: MacroExpansionErrorMessage(
51+
"Unsupported string segment type.")))
52+
return ""
53+
}
54+
}
55+
56+
guard string.allSatisfy(\.isASCII) else {
57+
context.diagnose(
58+
.init(
59+
node: node,
60+
message: MacroExpansionErrorMessage(
61+
"Magic bytes must be ASCII only.")))
62+
return ""
63+
}
64+
65+
guard !string.isEmpty else {
66+
context.diagnose(
67+
.init(
68+
node: node,
69+
message: MacroExpansionErrorMessage(
70+
"Magic bytes string cannot be empty.")))
71+
return ""
72+
}
73+
74+
var parsingExpr = "input"
75+
if let parsingArg = node.arguments.first(where: {
76+
$0.label?.text == "parsing"
77+
}) {
78+
parsingExpr = parsingArg.expression.description
79+
}
80+
81+
let bytes = Array(string.utf8)
82+
let byteValues = bytes.map { String($0) }.joined(separator: ", ")
83+
84+
return """
85+
_loadAndCheckInlineArrayBytes(\
86+
parsing: \(raw: parsingExpr), \
87+
expectedBytes: [\(raw: byteValues)])
88+
"""
89+
}
90+
}

Sources/BinaryParsingMacros/Plugin.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,6 @@ struct ParserMacroPlugin: CompilerPlugin {
1717
var providingMacros: [Macro.Type] = [
1818
ParserMacro.self,
1919
MagicNumberStringMacro.self,
20+
MagicMacro.self,
2021
]
2122
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift Binary Parsing open source project
4+
//
5+
// Copyright (c) 2025 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See https://swift.org/LICENSE.txt for license information
9+
//
10+
//===----------------------------------------------------------------------===//
11+
12+
import BinaryParsing
13+
import BinaryParsingMacros
14+
import MacroTesting
15+
import Testing
16+
17+
@Suite(
18+
.macros(macros: ["magic": MagicMacro.self])
19+
)
20+
struct MagicMacroTests {
21+
// MARK: Macro expansion tests
22+
@Test
23+
func magicStringAsciiOnly() {
24+
assertMacro {
25+
#"try #magic("test", parsing: &data)"#
26+
} expansion: {
27+
"try _loadAndCheckInlineArrayBytes(parsing: &data, expectedBytes: [116, 101, 115, 116])"
28+
}
29+
}
30+
31+
@Test
32+
func magicStringLong() {
33+
assertMacro {
34+
#"try #magic("hello world", parsing: &data)"#
35+
} expansion: {
36+
"try _loadAndCheckInlineArrayBytes(parsing: &data, expectedBytes: [104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100])"
37+
}
38+
}
39+
40+
@Test
41+
func magicStringSingleByte() {
42+
assertMacro {
43+
#"try #magic("A", parsing: &data)"#
44+
} expansion: {
45+
"try _loadAndCheckInlineArrayBytes(parsing: &data, expectedBytes: [65])"
46+
}
47+
}
48+
49+
@Test
50+
func magicStringWithLiteralBackslashN() {
51+
// Note: \n is treated as literal backslash + n characters, not a newline
52+
assertMacro {
53+
#"try #magic("hello\nworld", parsing: &data)"#
54+
} expansion: {
55+
"try _loadAndCheckInlineArrayBytes(parsing: &data, expectedBytes: [104, 101, 108, 108, 111, 92, 110, 119, 111, 114, 108, 100])"
56+
}
57+
}
58+
59+
@Test
60+
func magicStringEmpty() {
61+
assertMacro {
62+
#"try #magic("", parsing: &data)"#
63+
} diagnostics: {
64+
"""
65+
try #magic("", parsing: &data)
66+
┬─────────────────────────
67+
╰─ 🛑 Magic bytes string cannot be empty.
68+
"""
69+
}
70+
}
71+
72+
@Test
73+
func magicCustomParsingArgument() {
74+
assertMacro {
75+
#"try #magic("test", parsing: &mySpan)"#
76+
} expansion: {
77+
"try _loadAndCheckInlineArrayBytes(parsing: &mySpan, expectedBytes: [116, 101, 115, 116])"
78+
}
79+
}
80+
81+
// MARK: End-to-end runtime tests
82+
@available(macOS 26, iOS 26, watchOS 26, tvOS 26, visionOS 26, *)
83+
@Test
84+
func magicEndToEndMatching() throws {
85+
let testBytes: [UInt8] = [116, 101, 115, 116] // "test"
86+
87+
try testBytes.withParserSpan { span in
88+
// This should succeed - bytes match
89+
try #magic("test", parsing: &span)
90+
#expect(span.count == 0)
91+
}
92+
}
93+
94+
@available(macOS 26, iOS 26, watchOS 26, tvOS 26, visionOS 26, *)
95+
@Test
96+
func magicEndToEndMismatched() throws {
97+
let wrongBytes: [UInt8] = [74, 80, 69, 71] // "JPEG"
98+
99+
wrongBytes.withParserSpan { span in
100+
// This should fail - bytes don't match
101+
#expect(throws: ParsingError.self) {
102+
try #magic("test", parsing: &span)
103+
}
104+
// Span should still be consumed even though comparison failed
105+
#expect(span.count == 0)
106+
}
107+
}
108+
109+
@available(macOS 26, iOS 26, watchOS 26, tvOS 26, visionOS 26, *)
110+
@Test
111+
func magicEndToEndLongString() throws {
112+
let longTestBytes: [UInt8] = Array("hello world".utf8)
113+
114+
try longTestBytes.withParserSpan { span in
115+
// Test arbitrary length support
116+
try #magic("hello world", parsing: &span)
117+
#expect(span.count == 0)
118+
}
119+
}
120+
121+
@available(macOS 26, iOS 26, watchOS 26, tvOS 26, visionOS 26, *)
122+
@Test
123+
func magicEndToEndInsufficientBytes() throws {
124+
let shortBytes: [UInt8] = [116, 101] // "te" (only 2 bytes)
125+
126+
shortBytes.withParserSpan { span in
127+
// This should fail - not enough bytes
128+
#expect(throws: ParsingError.self) {
129+
try #magic("test", parsing: &span) // needs 4 bytes
130+
}
131+
}
132+
}
133+
}

Tests/BinaryParsingTests/InlineArrayParsingTests.swift

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,4 +173,35 @@ struct InlineArrayParsingTests {
173173
#expect(parsedArray == expectedArray)
174174
}
175175
}
176+
177+
@available(macOS 26, iOS 26, watchOS 26, tvOS 26, visionOS 26, *)
178+
@Test
179+
func magicFunctionMatchingBytes() throws {
180+
let correctBytes: [UInt8] = [116, 101, 115, 116] // "test"
181+
182+
try correctBytes.withParserSpan { span in
183+
let expectedArray: InlineArray<4, UInt8> = [116, 101, 115, 116]
184+
// This should succeed - bytes match
185+
try _loadAndCheckInlineArrayBytes(
186+
parsing: &span, expectedBytes: expectedArray)
187+
#expect(span.count == 0)
188+
}
189+
}
190+
191+
@available(macOS 26, iOS 26, watchOS 26, tvOS 26, visionOS 26, *)
192+
@Test
193+
func magicFunctionMismatchedBytes() throws {
194+
let wrongBytes: [UInt8] = [74, 80, 69, 71] // "JPEG"
195+
196+
wrongBytes.withParserSpan { span in
197+
let expectedArray: InlineArray<4, UInt8> = [116, 101, 115, 116]
198+
// This should fail - bytes don't match
199+
#expect(throws: ParsingError.self) {
200+
try _loadAndCheckInlineArrayBytes(
201+
parsing: &span, expectedBytes: expectedArray)
202+
}
203+
// Span should be consumed even though comparison failed
204+
#expect(span.count == 0)
205+
}
206+
}
176207
}

0 commit comments

Comments
 (0)