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
1 change: 1 addition & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ let package = Package(
.testTarget(
name: "BinaryParsingMacrosTests",
dependencies: [
"BinaryParsing",
"BinaryParsingMacros",
.product(name: "SwiftSyntax", package: "swift-syntax"),
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
Expand Down
63 changes: 63 additions & 0 deletions Sources/BinaryParsing/Macros/Macros.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,66 @@
@freestanding(expression)
public macro magicNumber(_ code: String, parsing input: inout ParserSpan) =
#externalMacro(module: "BinaryParsingMacros", type: "MagicNumberStringMacro")

/// Parses and validates a magic number or signature of arbitrary length from binary data.
///
/// This macro extends the functionality of `#magicNumber` to support ASCII strings of any length,
/// not just the 2, 4, or 8 byte limitations of the original macro. It compiles to an optimized
/// `InlineArray<N, UInt8>` comparison with zero runtime overhead.
///
/// The macro takes an ASCII string literal and generates compile-time code that:
/// 1. Parses exactly `string.count` bytes from the input span
/// 2. Compares them against the expected byte values
/// 3. Throws a `ParsingError` if bytes don't match or insufficient data is available
///
/// ## Usage
///
/// ```swift
/// // Parse 4-byte magic number
/// try #magic("test", parsing: &input)
///
/// // Parse single byte
/// try #magic("A", parsing: &input)
///
/// // Parse longer sequences (e.g., 11 bytes)
/// try #magic("hello world", parsing: &input)
///
/// ```
///
/// ## Compile-time Optimization
///
/// The macro generates code equivalent to:
/// ```swift
/// _loadAndCheckInlineArrayBytes(
/// parsing: &input,
/// expectedBytes: [116, 101, 115, 116] // ASCII values of "test"
/// )
/// ```
///
/// This leverages Swift 6.2's `InlineArray` and Value Generics for compile-time resolution,
/// providing the same performance as hand-written byte comparisons.
///
/// ## Error Conditions
///
/// Throws `ParsingError` in these cases:
/// - Input span has fewer bytes than the magic string length
/// - Parsed bytes don't match the expected magic string
/// - Magic string contains non-ASCII characters
/// - Magic string is empty
///
/// ## Availability
///
/// Requires macOS 26+, iOS 26+, watchOS 26+, tvOS 26+, visionOS 26+ due to `InlineArray` usage.
///
/// ## See Also
///
/// - `#magicNumber(_:parsing:)` for 2/4/8 byte magic numbers using fixed-width integers
/// - `InlineArray` for the underlying compile-time array implementation
///
/// - Parameters:
/// - code: An ASCII string literal representing the expected magic bytes
/// - input: An inout `ParserSpan` to parse from
/// - Throws: `ParsingError` if parsing fails or bytes don't match
@freestanding(expression)
public macro magic(_ code: String, parsing input: inout ParserSpan) =
#externalMacro(module: "BinaryParsingMacros", type: "MagicMacro")
45 changes: 45 additions & 0 deletions Sources/BinaryParsing/Macros/MagicNumber.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,48 @@ public func _loadAndCheckDirectBytesByteOrder<
location: input.startPosition)
}
}

/// Loads and validates bytes from a parser span against an expected InlineArray of bytes.
///
/// This function is used internally by the `#magic` macro to perform arbitrary-length
/// magic number validation using InlineArray for compile-time optimization.
///
/// The function:
/// 1. Parses exactly `N` bytes from the input span into an `InlineArray<N, UInt8>`
/// 2. Compares the parsed bytes against the expected bytes using InlineArray's Equatable conformance
/// 3. Throws a `ParsingError` with `.invalidValue` status if bytes don't match
/// 4. Consumes the bytes from the span regardless of whether comparison succeeds or fails
///
/// ## Usage
///
/// This function is primarily intended for use by macro-generated code:
/// ```swift
/// // Generated by #magic("test", parsing: &input)
/// try _loadAndCheckInlineArrayBytes(
/// parsing: &input,
/// expectedBytes: [116, 101, 115, 116]
/// )
/// ```
///
/// ## Availability
///
/// Requires macOS 26+, iOS 26+, watchOS 26+, tvOS 26+, visionOS 26+ due to `InlineArray` usage.
///
/// - Parameters:
/// - input: An inout `ParserSpan` to parse bytes from
/// - expectedBytes: An `InlineArray<N, UInt8>` containing the expected byte values
/// - Throws: `ParsingError` with `.invalidValue` status if bytes don't match, or propagates
/// any parsing errors from `InlineArray.init(parsing:)`
@available(macOS 26, iOS 26, watchOS 26, tvOS 26, visionOS 26, *)
@_lifetime(&input)
@inlinable
public func _loadAndCheckInlineArrayBytes<let N: Int>(
parsing input: inout ParserSpan,
expectedBytes: InlineArray<N, UInt8>
) throws(ParsingError) {
let parsedBytes = try? InlineArray<N, UInt8>(parsing: &input)
guard parsedBytes == expectedBytes else {
throw ParsingError(
status: .invalidValue, location: input.startPosition)
}
}
15 changes: 15 additions & 0 deletions Sources/BinaryParsing/Parsers/InlineArray.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,21 @@ extension InlineArray where Element == UInt8 {
}
}

@available(macOS 26, iOS 26, watchOS 26, tvOS 26, visionOS 26, *)
extension InlineArray: @retroactive Equatable where Element == UInt8 {
@inlinable
public static func == (
lhs: InlineArray<count, UInt8>, rhs: InlineArray<count, UInt8>
) -> Bool {
for i in 0..<count {
if lhs[i] != rhs[i] {
return false
}
}
return true
}
}

@available(macOS 26, iOS 26, watchOS 26, tvOS 26, visionOS 26, *)
extension InlineArray where Element: ~Copyable {
/// Creates a new array by parsing the specified number of elements from the given
Expand Down
90 changes: 90 additions & 0 deletions Sources/BinaryParsingMacros/MagicMacro.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift Binary Parsing open source project
//
// Copyright (c) 2025 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
//
//===----------------------------------------------------------------------===//

import SwiftDiagnostics
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros

public struct MagicMacro: ExpressionMacro {
public static func expansion(
of node: some FreestandingMacroExpansionSyntax,
in context: some MacroExpansionContext
) -> ExprSyntax {
guard let argument = node.arguments.first?.expression,
let stringLiteral = argument.as(StringLiteralExprSyntax.self)
else {
context.diagnose(
.init(
node: node,
message: MacroExpansionErrorMessage(
"Magic bytes must be expressed as a string literal.")))
return ""
}

// Handle both single-segment and multi-segment strings (for escape sequences)
var string = ""
for segment in stringLiteral.segments {
switch segment {
case .stringSegment(let literalSegment):
string += literalSegment.content.text
case .expressionSegment(_):
context.diagnose(
.init(
node: node,
message: MacroExpansionErrorMessage(
"String interpolation not supported in magic bytes.")))
return ""
@unknown default:
context.diagnose(
.init(
node: node,
message: MacroExpansionErrorMessage(
"Unsupported string segment type.")))
return ""
}
}

guard string.allSatisfy(\.isASCII) else {
context.diagnose(
.init(
node: node,
message: MacroExpansionErrorMessage(
"Magic bytes must be ASCII only.")))
return ""
}

guard !string.isEmpty else {
context.diagnose(
.init(
node: node,
message: MacroExpansionErrorMessage(
"Magic bytes string cannot be empty.")))
return ""
}

var parsingExpr = "input"
if let parsingArg = node.arguments.first(where: {
$0.label?.text == "parsing"
}) {
parsingExpr = parsingArg.expression.description
}

let bytes = Array(string.utf8)
let byteValues = bytes.map { String($0) }.joined(separator: ", ")

return """
_loadAndCheckInlineArrayBytes(\
parsing: \(raw: parsingExpr), \
expectedBytes: [\(raw: byteValues)])
"""
}
}
1 change: 1 addition & 0 deletions Sources/BinaryParsingMacros/Plugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,6 @@ struct ParserMacroPlugin: CompilerPlugin {
var providingMacros: [Macro.Type] = [
ParserMacro.self,
MagicNumberStringMacro.self,
MagicMacro.self,
]
}
133 changes: 133 additions & 0 deletions Tests/BinaryParsingMacrosTests/MagicMacroTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
//===----------------------------------------------------------------------===//
//
// This source file is part of the Swift Binary Parsing open source project
//
// Copyright (c) 2025 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
//
//===----------------------------------------------------------------------===//

import BinaryParsing
import BinaryParsingMacros
import MacroTesting
import Testing

@Suite(
.macros(macros: ["magic": MagicMacro.self])
)
struct MagicMacroTests {
// MARK: Macro expansion tests
@Test
func magicStringAsciiOnly() {
assertMacro {
#"try #magic("test", parsing: &data)"#
} expansion: {
"try _loadAndCheckInlineArrayBytes(parsing: &data, expectedBytes: [116, 101, 115, 116])"
}
}

@Test
func magicStringLong() {
assertMacro {
#"try #magic("hello world", parsing: &data)"#
} expansion: {
"try _loadAndCheckInlineArrayBytes(parsing: &data, expectedBytes: [104, 101, 108, 108, 111, 32, 119, 111, 114, 108, 100])"
}
}

@Test
func magicStringSingleByte() {
assertMacro {
#"try #magic("A", parsing: &data)"#
} expansion: {
"try _loadAndCheckInlineArrayBytes(parsing: &data, expectedBytes: [65])"
}
}

@Test
func magicStringWithLiteralBackslashN() {
// Note: \n is treated as literal backslash + n characters, not a newline
assertMacro {
#"try #magic("hello\nworld", parsing: &data)"#
} expansion: {
"try _loadAndCheckInlineArrayBytes(parsing: &data, expectedBytes: [104, 101, 108, 108, 111, 92, 110, 119, 111, 114, 108, 100])"
}
}

@Test
func magicStringEmpty() {
assertMacro {
#"try #magic("", parsing: &data)"#
} diagnostics: {
"""
try #magic("", parsing: &data)
┬─────────────────────────
╰─ 🛑 Magic bytes string cannot be empty.
"""
}
}

@Test
func magicCustomParsingArgument() {
assertMacro {
#"try #magic("test", parsing: &mySpan)"#
} expansion: {
"try _loadAndCheckInlineArrayBytes(parsing: &mySpan, expectedBytes: [116, 101, 115, 116])"
}
}

// MARK: End-to-end runtime tests
@available(macOS 26, iOS 26, watchOS 26, tvOS 26, visionOS 26, *)
@Test
func magicEndToEndMatching() throws {
let testBytes: [UInt8] = [116, 101, 115, 116] // "test"

try testBytes.withParserSpan { span in
// This should succeed - bytes match
try #magic("test", parsing: &span)
#expect(span.count == 0)
}
}

@available(macOS 26, iOS 26, watchOS 26, tvOS 26, visionOS 26, *)
@Test
func magicEndToEndMismatched() throws {
let wrongBytes: [UInt8] = [74, 80, 69, 71] // "JPEG"

wrongBytes.withParserSpan { span in
// This should fail - bytes don't match
#expect(throws: ParsingError.self) {
try #magic("test", parsing: &span)
}
// Span should still be consumed even though comparison failed
#expect(span.count == 0)
}
}

@available(macOS 26, iOS 26, watchOS 26, tvOS 26, visionOS 26, *)
@Test
func magicEndToEndLongString() throws {
let longTestBytes: [UInt8] = Array("hello world".utf8)

try longTestBytes.withParserSpan { span in
// Test arbitrary length support
try #magic("hello world", parsing: &span)
#expect(span.count == 0)
}
}

@available(macOS 26, iOS 26, watchOS 26, tvOS 26, visionOS 26, *)
@Test
func magicEndToEndInsufficientBytes() throws {
let shortBytes: [UInt8] = [116, 101] // "te" (only 2 bytes)

shortBytes.withParserSpan { span in
// This should fail - not enough bytes
#expect(throws: ParsingError.self) {
try #magic("test", parsing: &span) // needs 4 bytes
}
}
}
}
Loading