diff --git a/Package.swift b/Package.swift index 345b9e297..f8a466e5d 100644 --- a/Package.swift +++ b/Package.swift @@ -206,12 +206,18 @@ if generateCode { ] ) ] - package.targets[0].plugins = [ - "GenerateBluetoothDefinitions" - ] - package.targets[4].plugins = [ - "GenerateBluetoothDefinitions" - ] + // Name-based lookup, not a hardcoded index — the target array's + // order has changed before (silently detaching this plugin from + // BluetoothTests when a new target was inserted ahead of it) and + // will again. + for name in ["Bluetooth", "BluetoothTests"] { + guard let index = package.targets.firstIndex(where: { $0.name == name }) else { + fatalError("Missing target \(name)") + } + package.targets[index].plugins = [ + "GenerateBluetoothDefinitions" + ] + } } if enableMacros { diff --git a/Plugins/GenerateBluetoothDefinitions/BluetoothUUIDMetadata.swift b/Plugins/GenerateBluetoothDefinitions/BluetoothUUIDMetadata.swift new file mode 100644 index 000000000..933a0d9fb --- /dev/null +++ b/Plugins/GenerateBluetoothDefinitions/BluetoothUUIDMetadata.swift @@ -0,0 +1,38 @@ +// +// BluetoothUUIDMetadata.swift +// Bluetooth +// +// Created by Alsey Coleman Miller on 1/12/25. +// + +import Foundation +import PackagePlugin + +extension GenerateBluetoothDefinitionsPlugin { + + func bluetoothUUIDMetadataBuildCommands( + for context: PluginContext, + target: SwiftSourceModuleTarget, + commands: inout [Command] + ) throws { + guard target.name == "Bluetooth" else { + return + } + // Generate Bluetooth UUID Metadata (name/type lookup, merged + // across all categories) + let outputDirectory = context.pluginWorkDirectoryURL + let outputURLs = [ + outputDirectory.appending(component: "BluetoothUUIDMetadataNames.swift") + ] + let arguments = ["uuidMetadata"] + outputURLs.map { $0.path() } + let command = Command.buildCommand( + displayName: "Generate Bluetooth UUID Metadata", + executable: try context.tool(named: "GenerateBluetooth").url, + arguments: arguments, + inputFiles: [], + outputFiles: outputURLs + ) + assert(arguments.count == 2) + commands.append(command) + } +} diff --git a/Plugins/GenerateBluetoothDefinitions/CompanyIdentifierMetadata.swift b/Plugins/GenerateBluetoothDefinitions/CompanyIdentifierMetadata.swift new file mode 100644 index 000000000..f1bdfcfc9 --- /dev/null +++ b/Plugins/GenerateBluetoothDefinitions/CompanyIdentifierMetadata.swift @@ -0,0 +1,37 @@ +// +// CompanyIdentifierMetadata.swift +// Bluetooth +// +// Created by Alsey Coleman Miller on 1/12/25. +// + +import Foundation +import PackagePlugin + +extension GenerateBluetoothDefinitionsPlugin { + + func companyIdentifierMetadataBuildCommands( + for context: PluginContext, + target: SwiftSourceModuleTarget, + commands: inout [Command] + ) throws { + guard target.name == "Bluetooth" else { + return + } + // Generate Bluetooth Company Identifier Metadata (name lookup) + let outputDirectory = context.pluginWorkDirectoryURL + let outputURLs = [ + outputDirectory.appending(component: "CompanyIdentifierNames.swift") + ] + let arguments = ["companyIdentifierMetadata"] + outputURLs.map { $0.path() } + let command = Command.buildCommand( + displayName: "Generate Bluetooth Company Identifier Metadata", + executable: try context.tool(named: "GenerateBluetooth").url, + arguments: arguments, + inputFiles: [], + outputFiles: outputURLs + ) + assert(arguments.count == 2) + commands.append(command) + } +} diff --git a/Plugins/GenerateBluetoothDefinitions/Plugin.swift b/Plugins/GenerateBluetoothDefinitions/Plugin.swift index 73c86e30b..6aae4bbac 100644 --- a/Plugins/GenerateBluetoothDefinitions/Plugin.swift +++ b/Plugins/GenerateBluetoothDefinitions/Plugin.swift @@ -21,6 +21,8 @@ struct GenerateBluetoothDefinitionsPlugin: BuildToolPlugin { // Generate Bluetooth Company Identifier Definitions try companyIdentifiersBuildCommands(for: context, target: target, commands: &commands) + // Generate Bluetooth Company Identifier Metadata (name lookup) + try companyIdentifierMetadataBuildCommands(for: context, target: target, commands: &commands) // Generate Bluetooth Company Identifier Unit Tests try companyIdentifierTestsBuildCommands(for: context, target: target, commands: &commands) // Generate Bluetooth Unit Identifier Definitions @@ -44,6 +46,8 @@ struct GenerateBluetoothDefinitionsPlugin: BuildToolPlugin { commands: &commands ) } + // Generate Bluetooth UUID Metadata (name/type lookup) + try bluetoothUUIDMetadataBuildCommands(for: context, target: target, commands: &commands) return commands } diff --git a/Sources/Bluetooth/BluetoothUUID.swift b/Sources/Bluetooth/BluetoothUUID.swift index d62dc9238..64979002e 100644 --- a/Sources/Bluetooth/BluetoothUUID.swift +++ b/Sources/Bluetooth/BluetoothUUID.swift @@ -56,7 +56,7 @@ extension BluetoothUUID: Equatable { extension BluetoothUUID: CustomStringConvertible { public var description: String { - #if Metadata && canImport(Foundation) && !os(WASI) && !hasFeature(Embedded) + #if Metadata if let name = self.metadata?.name { return "\(rawValue) (\(name))" } else { @@ -73,7 +73,7 @@ extension BluetoothUUID: CustomStringConvertible { extension BluetoothUUID: LosslessStringConvertible { public init?(_ string: String) { - #if Metadata && canImport(Foundation) && !os(WASI) && !hasFeature(Embedded) + #if Metadata var rawValue = string var name: String? // Find UUID name diff --git a/Sources/Bluetooth/BluetoothUUIDMetadata.swift b/Sources/Bluetooth/BluetoothUUIDMetadata.swift index 01b81186e..8851f633e 100644 --- a/Sources/Bluetooth/BluetoothUUIDMetadata.swift +++ b/Sources/Bluetooth/BluetoothUUIDMetadata.swift @@ -5,37 +5,36 @@ // Created by Alsey Coleman Miller on 1/12/25. // -#if Metadata && canImport(Foundation) && !os(WASI) && !hasFeature(Embedded) -#if canImport(FoundationEssentials) -import FoundationEssentials -#else -import Foundation +#if Metadata +public extension BluetoothUUID { + + /// Bluetooth SIG assigned-numbers metadata for a 16-bit UUID. + struct Metadata: Equatable, Hashable, Sendable { + + /// The name of the defined UUID. + public let name: String + + /// The Bluetooth type namespace of the UUID. + public let type: String? + } +} + +#if !hasFeature(Embedded) +extension BluetoothUUID.Metadata: Codable {} #endif -import BluetoothMetadata public extension BluetoothUUID { /// Fetch the metadata for the UUID. - var metadata: BluetoothMetadata.BluetoothUUID? { - guard case let .bit16(rawValue) = self else { + /// + /// - SeeAlso: [Assigned Numbers](https://www.bluetooth.com/specifications/assigned-numbers/) + var metadata: Metadata? { + guard case let .bit16(rawValue) = self, + let generated = Self.generatedMetadata(for: rawValue) + else { return nil } - for file in files.values { - if let metadata = file[rawValue] { - return metadata - } - } - return nil + return Metadata(name: generated.name, type: generated.type) } } - -internal let files: [BluetoothMetadata.BluetoothUUID.Category: BluetoothMetadata.BluetoothUUID.File] = { - do { - return try BluetoothMetadata.BluetoothUUID.File.load() - } catch { - assertionFailure("Unable to load metadata: \(error)") - return [:] - } -}() - #endif diff --git a/Sources/Bluetooth/CompanyIdentifier.swift b/Sources/Bluetooth/CompanyIdentifier.swift index 7e5d0dd61..9e4a7d658 100644 --- a/Sources/Bluetooth/CompanyIdentifier.swift +++ b/Sources/Bluetooth/CompanyIdentifier.swift @@ -50,7 +50,7 @@ extension CompanyIdentifier: ExpressibleByIntegerLiteral { extension CompanyIdentifier: CustomStringConvertible { public var description: String { - #if Metadata && canImport(Foundation) && !os(WASI) && !hasFeature(Embedded) + #if Metadata return name ?? rawValue.description #else return rawValue.description diff --git a/Sources/Bluetooth/CompanyIdentifierMetadata.swift b/Sources/Bluetooth/CompanyIdentifierMetadata.swift index d71f0ce91..81f06c492 100644 --- a/Sources/Bluetooth/CompanyIdentifierMetadata.swift +++ b/Sources/Bluetooth/CompanyIdentifierMetadata.swift @@ -5,36 +5,14 @@ // Created by Alsey Coleman Miller on 1/12/25. // -#if Metadata && canImport(Foundation) && !os(WASI) && !hasFeature(Embedded) -#if canImport(FoundationEssentials) -import FoundationEssentials -#else -import Foundation -#endif -import BluetoothMetadata - +#if Metadata public extension CompanyIdentifier { /// Bluetooth Company name. /// /// - SeeAlso: [Company Identifiers](https://www.bluetooth.com/specifications/assigned-numbers/company-identifiers) var name: String? { - companyIdentifiers[rawValue] + Self.generatedName(for: rawValue) } } - -internal let companyIdentifiers: [UInt16: String] = { - do { - let file = try BluetoothMetadata.CompanyIdentifier.File.load() - var companyIdentifiers = [UInt16: String]() - for element in file.companyIdentifiers { - companyIdentifiers[element.id] = element.name - } - assert(companyIdentifiers.count == file.companyIdentifiers.count) - return companyIdentifiers - } catch { - assertionFailure("Unable to load metadata: \(error)") - return [:] - } -}() #endif diff --git a/Sources/Bluetooth/UnitIdentifier.swift b/Sources/Bluetooth/UnitIdentifier.swift index a15bb2978..9e635e023 100644 --- a/Sources/Bluetooth/UnitIdentifier.swift +++ b/Sources/Bluetooth/UnitIdentifier.swift @@ -30,7 +30,7 @@ extension UnitIdentifier: ExpressibleByIntegerLiteral { extension UnitIdentifier: CustomStringConvertible { public var description: String { - #if Metadata && canImport(Foundation) && !os(WASI) && !hasFeature(Embedded) + #if Metadata return self.name ?? rawValueDescription #else return rawValueDescription diff --git a/Sources/Bluetooth/UnitIdentifierMetadata.swift b/Sources/Bluetooth/UnitIdentifierMetadata.swift index 1c0cd3c10..9a592de68 100644 --- a/Sources/Bluetooth/UnitIdentifierMetadata.swift +++ b/Sources/Bluetooth/UnitIdentifierMetadata.swift @@ -5,14 +5,7 @@ // Created by Alsey Coleman Miller on 1/12/25. // -#if Metadata && canImport(Foundation) && !os(WASI) && !hasFeature(Embedded) -#if canImport(FoundationEssentials) -import FoundationEssentials -#else -import Foundation -#endif -import BluetoothMetadata - +#if Metadata public extension UnitIdentifier { /// The name of the unit. diff --git a/Sources/GenerateBluetooth/BluetoothUUIDMetadata.swift b/Sources/GenerateBluetooth/BluetoothUUIDMetadata.swift new file mode 100644 index 000000000..5669c9b8d --- /dev/null +++ b/Sources/GenerateBluetooth/BluetoothUUIDMetadata.swift @@ -0,0 +1,79 @@ +// +// BluetoothUUIDMetadata.swift +// +// +// Created by Alsey Coleman Miller on 1/12/25. +// + +import Foundation +import BluetoothMetadata + +extension GenerateTool { + + /// Categories merged in this fixed order when the same 16-bit value + /// appears in more than one — matches `BluetoothUUIDMetadata.swift`'s + /// prior runtime behavior of returning the first match found, but + /// deterministically instead of in unspecified dictionary order. + static var uuidMetadataCategoryOrder: [BluetoothMetadata.BluetoothUUID.Category] { + [.service, .characteristic, .declaration, .descriptor, .member, .unit] + } + + static func generateBluetoothUUIDMetadata(output: URL) throws { + var merged = [UInt16: BluetoothMetadata.BluetoothUUID]() + for category in uuidMetadataCategoryOrder { + let file = try BluetoothMetadata.BluetoothUUID.File(category) + for entry in file.uuids { + if let existing = merged[entry.uuid] { + print( + "warning: BluetoothUUID 0x\(entry.uuid.toHexadecimal()) is defined in more than one category" + + " (keeping \"\(existing.name)\", ignoring \"\(entry.name)\" from \(category))" + ) + continue + } + merged[entry.uuid] = entry + } + } + try generateBluetoothUUIDMetadataSwitch(merged, output: output) + } + + static func generateBluetoothUUIDMetadataSwitch( + _ data: [UInt16: BluetoothMetadata.BluetoothUUID], + output: URL + ) throws { + + var generatedCode = "" + let entries = data.values.sorted(by: { $0.uuid < $1.uuid }) + + func 🖨(_ text: String) { + generatedCode += text + "\n" + } + + 🖨("//") + 🖨("// \(output.lastPathComponent)") + 🖨("// Bluetooth") + 🖨("//") + 🖨("// Generated from Resources/{Service,Characteristic,Declaration,Descriptor,Member,Unit}UUID.json — do not edit.") + 🖨("//") + 🖨("") + 🖨("#if Metadata") + 🖨("extension BluetoothUUID {") + 🖨("") + 🖨(" // swiftlint:disable:next cyclomatic_complexity") + 🖨(" static func generatedMetadata(for rawValue: UInt16) -> (name: String, type: String?)? {") + 🖨(" switch rawValue {") + + for entry in entries { + let typeLiteral = entry.type.map { "#\"\($0)\"#" } ?? "nil" + 🖨(" case \(entry.uuid): return (#\"\(entry.name)\"#, \(typeLiteral))") + } + + 🖨(" default: return nil") + 🖨(" }") + 🖨(" }") + 🖨("}") + 🖨("#endif") + + try generatedCode.write(toFile: output.path, atomically: true, encoding: .utf8) + print("Generated \(output.path)") + } +} diff --git a/Sources/GenerateBluetooth/CompanyIdentifier.swift b/Sources/GenerateBluetooth/CompanyIdentifier.swift index 37c96e08d..2395150c2 100644 --- a/Sources/GenerateBluetooth/CompanyIdentifier.swift +++ b/Sources/GenerateBluetooth/CompanyIdentifier.swift @@ -88,13 +88,23 @@ extension GenerateTool { let data = try parseCompanyIdentifiersFile() var generatedCode = "" - let companies = companyIdentifiers(from: data) + // sorted, but not filtered — this backs a lossless correctness + // check, so every entry (including the internal-use 0xFFFF + // identifier) is covered. + let companies = data.sorted(by: { $0.key < $1.key }) func 🖨(_ text: String) { generatedCode += text + "\n" } - // generate unit test for extensions + // One #expect() call *site*, not one per entry: swift-testing's + // macro expansion cost scales with the number of source-level + // #expect occurrences, not with how many times each runs, so a + // few thousand individually-unrolled #expect calls (whether in + // one function or split across many) is many minutes of type + // checking. A single parameterized @Test(arguments:) — data + // description, runtime loop — keeps compile time bounded + // regardless of entry count. generatedCode = """ // // CompanyIdentifierTests.swift @@ -105,37 +115,44 @@ extension GenerateTool { import Testing @testable import Bluetooth - // swiftlint:disable type_body_length - #if !canImport(Darwin) + #if !canImport(Darwin) && Metadata @Suite struct CompanyIdentifierTests { - @Test func companies() { + struct Entry: Sendable { + let id: UInt16 + let name: String + } + // A single array-literal expression with thousands of + // elements is pathologically slow for the type checker, + // same as an unrolled #expect() chain. Sequential + // append() calls keep each statement's type-checking + // cost independent of the others. + static var entries: [Entry] { + var entries: [Entry] = [] + entries.reserveCapacity(\(companies.count)) """ - // generate test methods - - for (id, name, memberName) in companies { - - 🖨( - """ - // \(name) - #expect(CompanyIdentifier.\(memberName).rawValue == \(id)) - #expect(CompanyIdentifier.\(memberName).name == #\"\(name)\"#) - #expect(CompanyIdentifier.\(memberName).description == #\"\(name)\"#) - - """) + for (id, name) in companies { + 🖨(" entries.append(Entry(id: \(id), name: #\"\(name)\"#))") } 🖨( """ + return entries } + @Test(arguments: entries) + func company(_ entry: Entry) { + let identifier = CompanyIdentifier(rawValue: entry.id) + #expect(identifier.rawValue == entry.id) + #expect(identifier.name == entry.name) + #expect(identifier.description == entry.name) + } } #endif - // swiftlint:enable type_body_length """) try generatedCode.write(toFile: output.path, atomically: true, encoding: .utf8) diff --git a/Sources/GenerateBluetooth/CompanyIdentifierMetadata.swift b/Sources/GenerateBluetooth/CompanyIdentifierMetadata.swift new file mode 100644 index 000000000..f29df5545 --- /dev/null +++ b/Sources/GenerateBluetooth/CompanyIdentifierMetadata.swift @@ -0,0 +1,57 @@ +// +// CompanyIdentifierMetadata.swift +// +// +// Created by Alsey Coleman Miller on 1/12/25. +// + +import Foundation +import BluetoothMetadata + +extension GenerateTool { + + static func generateCompanyIdentifierMetadata(output: URL) throws { + let data = try parseCompanyIdentifiersFile() + try generateCompanyIdentifierMetadataSwitch(data, output: output) + } + + static func generateCompanyIdentifierMetadataSwitch(_ data: [UInt16: String], output: URL) throws { + + var generatedCode = "" + // sorted, but not filtered — unlike the numeric constant accessors, + // this backs the lossless `name` lookup, so every entry (including + // the internal-use 0xFFFF identifier) is kept. + let companies = data.sorted(by: { $0.key < $1.key }) + + func 🖨(_ text: String) { + generatedCode += text + "\n" + } + + 🖨("//") + 🖨("// \(output.lastPathComponent)") + 🖨("// Bluetooth") + 🖨("//") + 🖨("// Generated from Resources/CompanyIdentifier.json — do not edit.") + 🖨("//") + 🖨("") + 🖨("#if Metadata") + 🖨("extension CompanyIdentifier {") + 🖨("") + 🖨(" // swiftlint:disable:next cyclomatic_complexity") + 🖨(" static func generatedName(for rawValue: UInt16) -> String? {") + 🖨(" switch rawValue {") + + for (id, name) in companies { + 🖨(" case \(id): return #\"\(name)\"#") + } + + 🖨(" default: return nil") + 🖨(" }") + 🖨(" }") + 🖨("}") + 🖨("#endif") + + try generatedCode.write(toFile: output.path, atomically: true, encoding: .utf8) + print("Generated \(output.path)") + } +} diff --git a/Sources/GenerateBluetooth/Generate.swift b/Sources/GenerateBluetooth/Generate.swift index 1dfc86350..925b68a7e 100644 --- a/Sources/GenerateBluetooth/Generate.swift +++ b/Sources/GenerateBluetooth/Generate.swift @@ -30,6 +30,16 @@ struct GenerateTool { try generateCompanyIdentifiers( output: outputFile ) + case .companyIdentifierMetadata: + // parse arguments + guard arguments.count == 3 else { + throw CommandError.invalidArguments(arguments) + } + let outputFile = URL(fileURLWithPath: arguments[2]) + // generate files + try generateCompanyIdentifierMetadata( + output: outputFile + ) case .companyIdentifierTests: // parse arguments guard arguments.count == 3 else { @@ -55,7 +65,7 @@ struct GenerateTool { guard arguments.count == 3 else { throw CommandError.invalidArguments(arguments) } - let outputFile = URL(fileURLWithPath: arguments[3]) + let outputFile = URL(fileURLWithPath: arguments[2]) // generate files try generateUnitIdentifierTests( output: outputFile @@ -72,6 +82,16 @@ struct GenerateTool { type: uuidType, output: outputFile ) + case .uuidMetadata: + // parse arguments + guard arguments.count == 3 else { + throw CommandError.invalidArguments(arguments) + } + let outputFile = URL(fileURLWithPath: arguments[2]) + // generate files + try generateBluetoothUUIDMetadata( + output: outputFile + ) } } } @@ -79,10 +99,12 @@ struct GenerateTool { enum CommandType: String { case companyIdentifier + case companyIdentifierMetadata case companyIdentifierTests case unitIdentifier case unitIdentifierTests case uuid + case uuidMetadata } enum CommandError: Error { diff --git a/Sources/GenerateBluetooth/UnitIdentifier.swift b/Sources/GenerateBluetooth/UnitIdentifier.swift index db9a5181f..010f5158f 100644 --- a/Sources/GenerateBluetooth/UnitIdentifier.swift +++ b/Sources/GenerateBluetooth/UnitIdentifier.swift @@ -90,13 +90,17 @@ extension GenerateTool { let data = try parseUnitIdentifiersFile() var generatedCode = "" - let units = unitIdentifiers(from: data) + // sorted, but not filtered — every entry (including the + // internal-use identifier) is covered. + let units = data.sorted(by: { $0.key < $1.key }) func 🖨(_ text: String) { generatedCode += text + "\n" } - // generate unit test for extensions + // See generateCompanyIdentifierTests: one #expect() call site, + // driven by a parameterized @Test(arguments:), instead of one + // unrolled per entry. generatedCode = """ // // UnitIdentifierTests.swift @@ -107,37 +111,43 @@ extension GenerateTool { import Testing @testable import Bluetooth - // swiftlint:disable type_body_length + #if Metadata @Suite struct UnitIdentifierTests { - @Test func units() { + struct Entry: Sendable { + let id: UInt16 + let type: String + let name: String + } + // See generateCompanyIdentifierTests for why this is + // built with append() calls instead of an array literal. + static var entries: [Entry] { + var entries: [Entry] = [] + entries.reserveCapacity(\(units.count)) """ - // generate test methods - - for (id, name, type, memberName) in units { - let hexLiteral = "0x" + id.toHexadecimal() - let description = hexLiteral + " " + "(" + name + ")" - 🖨( - """ - // \(name) - #expect(UnitIdentifier.\(memberName).rawValue == \(hexLiteral)) - #expect(UnitIdentifier.\(memberName).type == #\"\(type)\"#) - #expect(UnitIdentifier.\(memberName).name == #\"\(name)\"#) - #expect(UnitIdentifier.\(memberName).description == #\"\(description)\"#) - - """) + for (id, metadata) in units { + 🖨(" entries.append(Entry(id: \(id), type: #\"\(metadata.id)\"#, name: #\"\(metadata.name)\"#))") } 🖨( """ + return entries } + @Test(arguments: entries) + func unit(_ entry: Entry) { + let identifier = UnitIdentifier(rawValue: entry.id) + #expect(identifier.rawValue == entry.id) + #expect(identifier.type == entry.type) + #expect(identifier.name == entry.name) + #expect(identifier.description == entry.name) + } } - // swiftlint:enable type_body_length + #endif """) try generatedCode.write(toFile: output.path, atomically: true, encoding: .utf8)