|
| 1 | +// |
| 2 | +// protoc-gen-fieldmeta-swift |
| 3 | +// |
| 4 | +// A pure-Swift protoc plugin that emits the FieldMetadataRegistry.swift for |
| 5 | +// (meshtastic.field_metadata) options — a Swift-native alternative to the Go |
| 6 | +// protoc-gen-fieldmeta in meshtastic/protobufs PR #952, for consumers whose |
| 7 | +// toolchain is already swift-protobuf based (Meshtastic-Apple). |
| 8 | +// |
| 9 | +// Built on SwiftProtobufPluginLibrary — the same library protoc-gen-swift itself |
| 10 | +// uses — so Swift type paths (`Config.PositionConfig`) and property names |
| 11 | +// (`rxGpio`) match the real generated code BY CONSTRUCTION (same NamingUtils), |
| 12 | +// rather than by reimplementing snake_case→camelCase and hoping it agrees. |
| 13 | +// |
| 14 | + |
| 15 | +import Foundation |
| 16 | +import SwiftProtobuf |
| 17 | +import SwiftProtobufPluginLibrary |
| 18 | + |
| 19 | +@main |
| 20 | +struct FieldMetaSwiftGenerator: CodeGenerator { |
| 21 | + |
| 22 | + var supportedFeatures: [Google_Protobuf_Compiler_CodeGeneratorResponse.Feature] { |
| 23 | + [.proto3Optional] |
| 24 | + } |
| 25 | + |
| 26 | + // Registering the extension here is what makes `field.options.fieldMetadata` |
| 27 | + // decode as a typed value instead of landing in unknownFields. |
| 28 | + var customOptionExtensions: [any AnyMessageExtension] { [Extensions_field_metadata] } |
| 29 | + |
| 30 | + var version: String? { "1.0.0 (swift)" } |
| 31 | + |
| 32 | + struct Entry { |
| 33 | + let swiftTypePath: String // e.g. Config.PositionConfig |
| 34 | + let protoTypeName: String // e.g. meshtastic.Config.PositionConfig |
| 35 | + let swiftFieldName: String // e.g. rxGpio |
| 36 | + let tag: Int32 |
| 37 | + let metadata: FieldMetadata |
| 38 | + } |
| 39 | + |
| 40 | + func generate( |
| 41 | + files: [FileDescriptor], |
| 42 | + parameter: any CodeGeneratorParameter, |
| 43 | + protoCompilerContext: any ProtoCompilerContext, |
| 44 | + generatorOutputs: any GeneratorOutputs |
| 45 | + ) throws { |
| 46 | + // Locate the FieldMetadata message descriptor (drives the emitted struct's |
| 47 | + // shape, so a schema-only attribute addition needs no plugin change). |
| 48 | + var metadataDescriptor: Descriptor? |
| 49 | + for file in files { |
| 50 | + if let d = file.messages.first(where: { $0.fullName == "meshtastic.FieldMetadata" }) { |
| 51 | + metadataDescriptor = d |
| 52 | + break |
| 53 | + } |
| 54 | + } |
| 55 | + guard let metadataDescriptor else { |
| 56 | + throw GenError.message("meshtastic.FieldMetadata not found among the input protos — include meshtastic/field_metadata.proto") |
| 57 | + } |
| 58 | + // Scalar-only guard, mirroring the Go plugin's hard error. |
| 59 | + for f in metadataDescriptor.fields { |
| 60 | + guard swiftScalarType(for: f.type) != nil, !f.isRepeated else { |
| 61 | + throw GenError.message("FieldMetadata.\(f.name): attributes must be scalar (bool/int/float/string), not repeated/message/enum/bytes") |
| 62 | + } |
| 63 | + } |
| 64 | + |
| 65 | + var entries: [Entry] = [] |
| 66 | + for file in files { |
| 67 | + let namer = SwiftProtobufNamer() |
| 68 | + var stack = file.messages |
| 69 | + while !stack.isEmpty { |
| 70 | + let message = stack.removeFirst() |
| 71 | + stack.append(contentsOf: message.messages) |
| 72 | + for field in message.fields where field.options.hasFieldMetadata { |
| 73 | + entries.append(Entry( |
| 74 | + swiftTypePath: namer.fullName(message: message), |
| 75 | + protoTypeName: message.fullName, |
| 76 | + swiftFieldName: namer.messagePropertyNames(field: field, prefixed: "", includeHasAndClear: false).name, |
| 77 | + tag: field.number, |
| 78 | + metadata: field.options.fieldMetadata |
| 79 | + )) |
| 80 | + } |
| 81 | + } |
| 82 | + } |
| 83 | + |
| 84 | + var out = "// DO NOT EDIT -- generated by protoc-gen-fieldmeta from (meshtastic.field_metadata) options.\n\n" |
| 85 | + |
| 86 | + // The FieldMetadata struct, shaped by the schema. |
| 87 | + out += "public struct FieldMetadata {\n" |
| 88 | + for f in metadataDescriptor.fields { |
| 89 | + out += " public var \(NamingUtils.toLowerCamelCase(f.name)): \(swiftScalarType(for: f.type)!)? = nil\n" |
| 90 | + } |
| 91 | + out += "}\n\n" |
| 92 | + |
| 93 | + // Typed accessors as extensions on the real swift-protobuf message types. |
| 94 | + var grouped: [String: [Entry]] = [:] |
| 95 | + var groupOrder: [String] = [] |
| 96 | + for e in entries { |
| 97 | + if grouped[e.swiftTypePath] == nil { groupOrder.append(e.swiftTypePath) } |
| 98 | + grouped[e.swiftTypePath, default: []].append(e) |
| 99 | + } |
| 100 | + for typePath in groupOrder { |
| 101 | + out += "extension \(typePath) {\n" |
| 102 | + for e in grouped[typePath]! { |
| 103 | + out += " public static var \(e.swiftFieldName): FieldMetadata { \(literal(for: e.metadata, shape: metadataDescriptor)) }\n" |
| 104 | + } |
| 105 | + out += "}\n\n" |
| 106 | + } |
| 107 | + |
| 108 | + // Low-level table + dynamic lookup. |
| 109 | + out += "public enum FieldMetadataRegistry {\n" |
| 110 | + out += " // Keyed by \"\\(messageType)#\\(tag)\".\n" |
| 111 | + out += " static let registry: [String: FieldMetadata] = [\n" |
| 112 | + for e in entries { |
| 113 | + out += " \"\(e.protoTypeName)#\(e.tag)\": \(literal(for: e.metadata, shape: metadataDescriptor)),\n" |
| 114 | + } |
| 115 | + out += " ]\n\n" |
| 116 | + out += " /// Metadata for the field with `tag` on `messageType`, or nil.\n" |
| 117 | + out += " public static func get(_ messageType: String, tag: Int) -> FieldMetadata? {\n" |
| 118 | + out += " return registry[\"\\(messageType)#\\(tag)\"]\n" |
| 119 | + out += " }\n" |
| 120 | + out += "}\n" |
| 121 | + |
| 122 | + try generatorOutputs.add(fileName: "FieldMetadataRegistry.swift", contents: out) |
| 123 | + } |
| 124 | + |
| 125 | + /// FieldMetadata(...) literal with only the explicitly-set attributes, in schema order. |
| 126 | + private func literal(for metadata: FieldMetadata, shape: Descriptor) -> String { |
| 127 | + var args: [String] = [] |
| 128 | + for f in shape.fields { |
| 129 | + let name = NamingUtils.toLowerCamelCase(f.name) |
| 130 | + switch f.name { |
| 131 | + case "diy_only": if metadata.hasDiyOnly { args.append("\(name): \(metadata.diyOnly)") } |
| 132 | + case "admin_only": if metadata.hasAdminOnly { args.append("\(name): \(metadata.adminOnly)") } |
| 133 | + case "min_value": if metadata.hasMinValue { args.append("\(name): \(metadata.minValue)") } |
| 134 | + case "max_value": if metadata.hasMaxValue { args.append("\(name): \(metadata.maxValue)") } |
| 135 | + case "unit": if metadata.hasUnit { args.append("\(name): \(swiftStringLiteral(metadata.unit))") } |
| 136 | + default: |
| 137 | + // A new schema attribute reached emission without plugin support — fail loudly |
| 138 | + // rather than silently dropping metadata (parity with the Go plugin's guard). |
| 139 | + fatalError("FieldMetadata attribute '\(f.name)' is not handled by protoc-gen-fieldmeta-swift; add a case") |
| 140 | + } |
| 141 | + } |
| 142 | + return "FieldMetadata(\(args.joined(separator: ", ")))" |
| 143 | + } |
| 144 | + |
| 145 | + private func swiftScalarType(for type: Google_Protobuf_FieldDescriptorProto.TypeEnum) -> String? { |
| 146 | + switch type { |
| 147 | + case .bool: return "Bool" |
| 148 | + case .double: return "Double" |
| 149 | + case .float: return "Float" |
| 150 | + case .string: return "String" |
| 151 | + case .int32, .sint32, .sfixed32: return "Int32" |
| 152 | + case .uint32, .fixed32: return "UInt32" |
| 153 | + case .int64, .sint64, .sfixed64: return "Int64" |
| 154 | + case .uint64, .fixed64: return "UInt64" |
| 155 | + default: return nil |
| 156 | + } |
| 157 | + } |
| 158 | + |
| 159 | + private func swiftStringLiteral(_ s: String) -> String { |
| 160 | + var escaped = "" |
| 161 | + for c in s.unicodeScalars { |
| 162 | + switch c { |
| 163 | + case "\"": escaped += "\\\"" |
| 164 | + case "\\": escaped += "\\\\" |
| 165 | + case "\n": escaped += "\\n" |
| 166 | + default: escaped.unicodeScalars.append(c) |
| 167 | + } |
| 168 | + } |
| 169 | + return "\"\(escaped)\"" |
| 170 | + } |
| 171 | + |
| 172 | + enum GenError: Error, CustomStringConvertible { |
| 173 | + case message(String) |
| 174 | + var description: String { |
| 175 | + switch self { |
| 176 | + case .message(let m): return m |
| 177 | + } |
| 178 | + } |
| 179 | + } |
| 180 | +} |
0 commit comments