Skip to content

Commit 85c6f52

Browse files
committed
tools: vendor protoc-gen-fieldmeta-swift (field metadata registry generator)
Pure-Swift protoc plugin generating FieldMetadataRegistry.swift from the (meshtastic.field_metadata) options proposed in meshtastic/protobufs#952, so the protobuf pipeline can adopt field metadata without adding Go to the toolchain. Built on SwiftProtobufPluginLibrary (the library protoc-gen-swift itself uses): type paths and property names come from SwiftProtobufNamer / NamingUtils, so emitted names match the app's real generated code by construction; the custom option arrives typed via customOptionExtensions. Verified: output byte-identical to PR #952's Go plugin over its schema (on swift-protobuf 1.36.1 and 1.38.1); the generated registry compiles inside the real MeshtasticProtobufs package and an external consumer typechecks the typed accessor, dynamic lookup, and static/instance member coexistence. Inert until protobufs#952 merges into the submodule — the gen_protos.sh wiring is documented in the tool's README as a one-liner for then. SwiftLint excludes cover the tool's .build and its generated binding; Generator.swift itself lints clean.
1 parent c819aa2 commit 85c6f52

6 files changed

Lines changed: 526 additions & 0 deletions

File tree

.swiftlint.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
# Exclude automatically generated Swift files
22
excluded:
33
- MeshtasticProtobufs
4+
- tools/protoc-gen-fieldmeta-swift/.build
5+
- tools/protoc-gen-fieldmeta-swift/Sources/protoc-gen-fieldmeta-swift/meshtastic
46

57
line_length: 400
68

tools/protoc-gen-fieldmeta-swift/Package.resolved

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// swift-tools-version:5.9
2+
import PackageDescription
3+
4+
let package = Package(
5+
name: "protoc-gen-fieldmeta-swift",
6+
platforms: [.macOS(.v10_15)],
7+
dependencies: [
8+
// Mirror MeshtasticProtobufs/Package.swift's requirement so this plugin's naming
9+
// engine (SwiftProtobufPluginLibrary/NamingUtils) resolves to the same version
10+
// that generates the app's .pb.swift files — keeping emitted type/property names
11+
// in lockstep with the real generated code.
12+
.package(url: "https://github.com/apple/swift-protobuf.git", from: "1.33.3")
13+
],
14+
targets: [
15+
.executableTarget(
16+
name: "protoc-gen-fieldmeta-swift",
17+
dependencies: [
18+
.product(name: "SwiftProtobuf", package: "swift-protobuf"),
19+
.product(name: "SwiftProtobufPluginLibrary", package: "swift-protobuf")
20+
]
21+
)
22+
]
23+
)
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# protoc-gen-fieldmeta-swift
2+
3+
A pure-Swift protoc plugin that generates `FieldMetadataRegistry.swift` from
4+
`(meshtastic.field_metadata)` field options — the structured, app-facing field
5+
metadata proposed in [meshtastic/protobufs#952](https://github.com/meshtastic/protobufs/pull/952)
6+
(e.g. `diy_only` on `Config.PositionConfig.rx_gpio`, so apps can hide DIY-only
7+
settings on pre-assembled boards).
8+
9+
This is the Swift-native equivalent of that PR's Go `protoc-gen-fieldmeta`,
10+
verified to produce **byte-identical output** over the same schema. It exists so
11+
this repo's protobuf pipeline stays entirely within the Swift toolchain — no Go
12+
required on contributor machines or CI.
13+
14+
## Why a Swift implementation
15+
16+
- Built on `SwiftProtobufPluginLibrary` — the same library `protoc-gen-swift`
17+
itself uses. Emitted type paths (`Config.PositionConfig`) and property names
18+
(`rxGpio`) come from swift-protobuf's own `SwiftProtobufNamer`/`NamingUtils`,
19+
so they match the app's real generated code **by construction** instead of by
20+
reimplementing the snake_case→camelCase rules and hoping they agree (fields
21+
like `pm10_standard` are exactly where hand-rolled conversions drift).
22+
- The `(meshtastic.field_metadata)` option is registered via the library's
23+
`customOptionExtensions` hook, so it arrives as a typed value on
24+
`field.options` — no descriptor byte-parsing.
25+
- The emitted `FieldMetadata` struct's shape is driven by the schema: adding a
26+
new *scalar* attribute to `field_metadata.proto` flows through automatically.
27+
(Value emission for a new attribute needs a one-line `case` in
28+
`Generator.swift` — the plugin fails loudly, never silently drops metadata.)
29+
30+
## Status
31+
32+
**Inert until meshtastic/protobufs#952 merges** — the `field_metadata.proto`
33+
option doesn't exist in the `protobufs/` submodule yet, so nothing invokes this
34+
plugin. It is vendored ahead of time so the pipeline change is a one-liner when
35+
upstream lands.
36+
37+
## Usage (once protobufs#952 is in the submodule)
38+
39+
Add to `scripts/gen_protos.sh`, alongside the existing `--swift_out`:
40+
41+
```bash
42+
swift build -c release --package-path tools/protoc-gen-fieldmeta-swift
43+
protoc --proto_path=./protobufs \
44+
--plugin=protoc-gen-fieldmetaswift=tools/protoc-gen-fieldmeta-swift/.build/release/protoc-gen-fieldmeta-swift \
45+
--fieldmetaswift_out=./MeshtasticProtobufs/Sources/meshtastic \
46+
./protobufs/meshtastic/*.proto
47+
```
48+
49+
The output (`FieldMetadataRegistry.swift`) lands next to the other generated
50+
sources in `MeshtasticProtobufs` and is used like:
51+
52+
```swift
53+
let hideOnRetail = Config.PositionConfig.rxGpio.diyOnly ?? false // typed accessor
54+
FieldMetadataRegistry.get("meshtastic.Config.PositionConfig", tag: 8) // dynamic lookup
55+
```
56+
57+
## Regenerating `field_metadata.pb.swift`
58+
59+
`Sources/protoc-gen-fieldmeta-swift/meshtastic/field_metadata.pb.swift` is the
60+
plugin's own generated binding for the option schema (currently generated from
61+
protobufs PR #952 at `007d7b2`, with the `protoc-gen-swift` matching
62+
`MeshtasticProtobufs`' resolved swift-protobuf version). Once the proto is in
63+
the submodule, regenerate with:
64+
65+
```bash
66+
protoc --proto_path=./protobufs \
67+
--swift_opt=Visibility=Public \
68+
--swift_out=tools/protoc-gen-fieldmeta-swift/Sources/protoc-gen-fieldmeta-swift \
69+
./protobufs/meshtastic/field_metadata.proto
70+
```
71+
72+
## Verification (2026-07-15, against protobufs PR #952 @ 007d7b2)
73+
74+
- Output diffed **byte-for-byte identical** to the Go `protoc-gen-fieldmeta`.
75+
- Generated registry compiles clean inside the real `MeshtasticProtobufs`
76+
package (swift-protobuf 1.36.1), and an external consumer typechecks the
77+
typed accessor, the dynamic lookup, and static/instance member coexistence
78+
(`config.rxGpio` value vs `Config.PositionConfig.rxGpio` metadata).
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
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

Comments
 (0)