@tabnas/proto 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,370 @@
1
+ /* Copyright (c) 2026 Richard Rodger and other contributors, MIT License */
2
+
3
+ // Walk the `{rule, src, kids}` CST produced by the proto ABNF grammar and
4
+ // assemble a FileDescriptorProto-shaped object. Lexical atoms arrive as
5
+ // whole-word tokens, and abnf's leading-ref inlining means the specific
6
+ // statement rule (message / field / enum …) is folded into the enclosing
7
+ // `topLevelDef` / `messageElement` dispatch node; we recover the statement
8
+ // kind from the keyword that precedes the node's first child (`kw`).
9
+
10
+ import {
11
+ FileDescriptorProto, DescriptorProto, FieldDescriptorProto,
12
+ EnumDescriptorProto, ServiceDescriptorProto, MethodDescriptorProto,
13
+ OneofDescriptorProto, OptionValue, FieldLabel, SCALAR_TYPES,
14
+ } from './descriptor'
15
+ import { ProtoVersion, isEdition } from './detect-version'
16
+
17
+ type Node = { rule?: string; src: string; kids?: Node[] }
18
+
19
+ // Children that are real rule nodes (terminals fold into `src`).
20
+ function R(n: Node): Node[] {
21
+ return (n.kids || []).filter((k) => k && k.rule)
22
+ }
23
+
24
+ // The keyword(s) consumed before this node's first child — the part of
25
+ // `src` ahead of the first child's `src`. For `message Foo {…}` the first
26
+ // child is `Foo`, so `kw` is `message`; for an unlabelled field it is ``.
27
+ function kw(n: Node): string {
28
+ const k = R(n)
29
+ if (0 === k.length) return n.src
30
+ const i = n.src.indexOf(k[0].src)
31
+ return i <= 0 ? '' : n.src.slice(0, i)
32
+ }
33
+
34
+ function child(n: Node, rule: string): Node | undefined {
35
+ return R(n).find((k) => k.rule === rule)
36
+ }
37
+
38
+ function unquote(s: string): string {
39
+ const m = s.match(/^["']([\s\S]*)["']$/)
40
+ return m ? m[1] : s
41
+ }
42
+
43
+ // ---- constants / option values -------------------------------------------
44
+
45
+ function constantValue(n: Node): OptionValue {
46
+ const s = n.src
47
+ if ('true' === s) return true
48
+ if ('false' === s) return false
49
+ if (/^["']/.test(s)) return unquote(s)
50
+ if (/^[-+]?(?:\d|\.\d|0x|0o|0b)/i.test(s)) {
51
+ const num = Number(s.replace(/^\+/, ''))
52
+ if (!Number.isNaN(num)) return num
53
+ }
54
+ return s // identifier (enum value name, inf, nan, …) kept verbatim
55
+ }
56
+
57
+ // The option name in an `optionName "=" constant` statement. Because the
58
+ // name's leading `optionNamePart` is inlined and the parts are split across
59
+ // the node's `src` and later children, read it as everything before the
60
+ // `constant`, with the trailing `=` removed.
61
+ function optionNameOf(stmt: Node, valueNode: Node | undefined): string {
62
+ if (!valueNode) return ''
63
+ const i = stmt.src.indexOf(valueNode.src)
64
+ return (i <= 0 ? '' : stmt.src.slice(0, i)).replace(/=$/, '')
65
+ }
66
+
67
+ // fieldOptions = "[" fieldOption *( "," fieldOption ) "]"
68
+ // fieldOption = optionName "=" constant
69
+ function readFieldOptions(opts: Node | undefined): Record<string, OptionValue> | undefined {
70
+ if (!opts) return undefined
71
+ const out: Record<string, OptionValue> = {}
72
+ for (const fo of R(opts)) {
73
+ const cst = child(fo, 'constant')
74
+ const name = optionNameOf(fo, cst)
75
+ if (cst && name) out[name] = constantValue(cst)
76
+ }
77
+ return Object.keys(out).length ? out : undefined
78
+ }
79
+
80
+ // ---- fields --------------------------------------------------------------
81
+
82
+ function fieldLabel(
83
+ labelNode: Node | undefined,
84
+ version: ProtoVersion,
85
+ ): { label: FieldLabel; proto3Optional?: boolean } {
86
+ const lab = labelNode?.src
87
+ if ('required' === lab) return { label: 'LABEL_REQUIRED' }
88
+ if ('repeated' === lab) return { label: 'LABEL_REPEATED' }
89
+ if ('optional' === lab) {
90
+ return 'proto3' === version
91
+ ? { label: 'LABEL_OPTIONAL', proto3Optional: true }
92
+ : { label: 'LABEL_OPTIONAL' }
93
+ }
94
+ // Implicit label.
95
+ return { label: 'LABEL_OPTIONAL' }
96
+ }
97
+
98
+ function fieldTypeName(typeText: string): Pick<FieldDescriptorProto, 'type' | 'typeName'> {
99
+ const bare = typeText.replace(/^\./, '')
100
+ const scalar = SCALAR_TYPES[bare]
101
+ if (scalar) return { type: scalar }
102
+ // Message or enum reference; resolution deferred, store as written.
103
+ return { type: 'TYPE_MESSAGE', typeName: typeText }
104
+ }
105
+
106
+ // A normal field: [label] fieldType ident "=" fieldNumber [fieldOptions].
107
+ // The surrounding dispatch node (`messageElement` / `oneofElement`) has these
108
+ // inlined as its children.
109
+ // The field type. Normally a `fieldType` child; but when `fieldType` is a
110
+ // leading ref (an unlabelled oneof member `string x = 1;`) abnf inlines it,
111
+ // so the type surfaces as a bare `fullIdent` / `messageType` child instead.
112
+ function typeNodeOf(n: Node): Node | undefined {
113
+ return child(n, 'fieldType') ?? child(n, 'messageType') ?? child(n, 'fullIdent')
114
+ }
115
+
116
+ function buildField(n: Node, version: ProtoVersion): FieldDescriptorProto {
117
+ const label = child(n, 'label')
118
+ const type = typeNodeOf(n)
119
+ const name = child(n, 'ident')
120
+ const number = child(n, 'fieldNumber')
121
+ const opts = child(n, 'fieldOptions')
122
+
123
+ const f: FieldDescriptorProto = {
124
+ name: name ? name.src : '',
125
+ number: number ? Number(number.src) : 0,
126
+ ...fieldLabel(label, version),
127
+ ...fieldTypeName(type ? type.src : ''),
128
+ }
129
+ const fo = readFieldOptions(opts)
130
+ if (fo) f.options = fo
131
+ return f
132
+ }
133
+
134
+ // map<K,V> name = N; -> a repeated message field whose type is a
135
+ // synthesised nested `<Name>Entry` message with mapEntry=true.
136
+ function buildMapField(
137
+ n: Node, version: ProtoVersion, into: DescriptorProto,
138
+ ): FieldDescriptorProto {
139
+ const types = R(n).filter((k) => k.rule === 'fieldType')
140
+ const name = child(n, 'ident')
141
+ const number = child(n, 'fieldNumber')
142
+ const keyText = types[0] ? types[0].src : ''
143
+ const valText = types[1] ? types[1].src : ''
144
+ const fname = name ? name.src : ''
145
+ const entryName = fname.replace(/(^|_)([a-z])/g, (_, a, b) => a + b.toUpperCase())
146
+ + 'Entry'
147
+
148
+ const entry: DescriptorProto = {
149
+ name: entryName,
150
+ field: [
151
+ { name: 'key', number: 1, label: 'LABEL_OPTIONAL', ...fieldTypeName(keyText) },
152
+ { name: 'value', number: 2, label: 'LABEL_OPTIONAL', ...fieldTypeName(valText) },
153
+ ],
154
+ nestedType: [], enumType: [], oneofDecl: [], extension: [],
155
+ options: { mapEntry: true },
156
+ }
157
+ into.nestedType.push(entry)
158
+
159
+ const f: FieldDescriptorProto = {
160
+ name: fname,
161
+ number: number ? Number(number.src) : 0,
162
+ label: 'LABEL_REPEATED',
163
+ type: 'TYPE_MESSAGE',
164
+ typeName: entryName,
165
+ }
166
+ const fo = readFieldOptions(child(n, 'fieldOptions'))
167
+ if (fo) f.options = fo
168
+ return f
169
+ }
170
+
171
+ // ---- enums ---------------------------------------------------------------
172
+
173
+ function buildEnum(n: Node): EnumDescriptorProto {
174
+ const e: EnumDescriptorProto = { name: child(n, 'ident')?.src ?? '', value: [] }
175
+ for (const el of R(n).filter((k) => k.rule === 'enumElement')) {
176
+ const k = kw(el)
177
+ if (k.startsWith('reserved')) {
178
+ addReserved(el, e)
179
+ continue
180
+ }
181
+ if (k.startsWith('option')) {
182
+ continue // enum-level option; folded into options below if needed
183
+ }
184
+ // enumField: ident "=" ["-"] fieldNumber -> name is the kw before "="
185
+ const name = kw(el).replace(/=.*$/, '')
186
+ const num = child(el, 'fieldNumber')
187
+ if (name && num) {
188
+ const neg = /=-/.test(el.src.replace(/\s+/g, ''))
189
+ e.value.push({ name, number: (neg ? -1 : 1) * Number(num.src) })
190
+ }
191
+ }
192
+ return e
193
+ }
194
+
195
+ // ---- reserved / extensions ranges ----------------------------------------
196
+
197
+ // `ranges = range *( "," range )`. The leading `range` is inlined into
198
+ // `ranges.src`, so parse the (whitespace-stripped) text rather than kids.
199
+ function ranges(rangesNode: Node | undefined): { start: number; end: number }[] {
200
+ if (!rangesNode) return []
201
+ const out: { start: number; end: number }[] = []
202
+ for (const part of rangesNode.src.replace(/\s+/g, '').split(',')) {
203
+ const m = part.match(/^(-?\d+)(?:to(-?\d+|max))?$/)
204
+ if (!m) continue
205
+ const start = Number(m[1])
206
+ const end = !m[2] ? start : (m[2] === 'max' ? 536870911 : Number(m[2]))
207
+ out.push({ start, end })
208
+ }
209
+ return out
210
+ }
211
+
212
+ function addReserved(n: Node, target: { reservedRange?: any[]; reservedName?: string[] }): void {
213
+ const rn = child(n, 'ranges')
214
+ if (rn) {
215
+ (target.reservedRange = target.reservedRange || []).push(...ranges(rn))
216
+ return
217
+ }
218
+ const names = child(n, 'fieldNames')
219
+ if (names) {
220
+ const list = R(names).filter((k) => k.rule === 'strLit').map((k) => unquote(k.src))
221
+ ;(target.reservedName = target.reservedName || []).push(...list)
222
+ }
223
+ }
224
+
225
+ // ---- messages ------------------------------------------------------------
226
+
227
+ function buildMessage(n: Node, version: ProtoVersion): DescriptorProto {
228
+ // `n` is a dispatch node whose `message` alt was inlined: kids are
229
+ // [ident, messageBody-children…] or [ident] then messageBody.
230
+ const msg: DescriptorProto = {
231
+ name: child(n, 'ident')?.src ?? '',
232
+ field: [], nestedType: [], enumType: [], oneofDecl: [], extension: [],
233
+ }
234
+ const body = child(n, 'messageBody')
235
+ const elements = body ? R(body).filter((k) => k.rule === 'messageElement') : []
236
+ for (const el of elements) addMessageElement(el, version, msg)
237
+ return msg
238
+ }
239
+
240
+ function addMessageElement(el: Node, version: ProtoVersion, msg: DescriptorProto): void {
241
+ const k = kw(el)
242
+ const first = R(el)[0]
243
+ if (k.startsWith('map<')) { msg.field.push(buildMapField(el, version, msg)); return }
244
+ if (k.startsWith('oneof')) { addOneof(el, version, msg); return }
245
+ if (k.startsWith('export') || k.startsWith('local')) {
246
+ // edition 2024 symbol visibility wraps the message/enum as a child node.
247
+ const m = child(el, 'message'); const e = child(el, 'enumDef')
248
+ if (m) msg.nestedType.push(buildMessage(m, version))
249
+ else if (e) msg.enumType.push(buildEnum(e))
250
+ return
251
+ }
252
+ if (k.startsWith('message')) { msg.nestedType.push(buildMessage(el, version)); return }
253
+ if (k.startsWith('enum')) { msg.enumType.push(buildEnum(el)); return }
254
+ if (k.startsWith('reserved')) { addReserved(el, msg); return }
255
+ if (k.startsWith('extensions')) {
256
+ (msg.extensionRange = msg.extensionRange || []).push(...ranges(child(el, 'ranges')))
257
+ return
258
+ }
259
+ if (k.startsWith('extend')) { addExtend(el, version, msg.extension); return }
260
+ if (k.startsWith('option')) {
261
+ msg.options = { ...(msg.options || {}), ...optionFrom(el) }; return
262
+ }
263
+ if (';' === el.src) return // emptyStmt
264
+ // No keyword and a fieldType/label lead => a field.
265
+ if (first && (first.rule === 'fieldType' || first.rule === 'label')) {
266
+ msg.field.push(buildField(el, version))
267
+ }
268
+ }
269
+
270
+ function addOneof(el: Node, version: ProtoVersion, msg: DescriptorProto): void {
271
+ const name = child(el, 'ident')?.src ?? ''
272
+ const index = msg.oneofDecl.length
273
+ msg.oneofDecl.push({ name })
274
+ for (const of of R(el).filter((k) => k.rule === 'oneofElement')) {
275
+ if (kw(of).startsWith('option')) continue
276
+ if (';' === of.src) continue
277
+ const f = buildField(of, version)
278
+ f.oneofIndex = index
279
+ delete f.proto3Optional // explicit oneof members aren't proto3-optional
280
+ msg.field.push(f)
281
+ }
282
+ }
283
+
284
+ function addExtend(el: Node, version: ProtoVersion, into: FieldDescriptorProto[]): void {
285
+ // extend messageType "{" *field "}" — fields inline as messageElement-like.
286
+ for (const f of R(el).filter((k) => k.rule === 'field' || k.rule === 'messageElement')) {
287
+ into.push(buildField(f, version))
288
+ }
289
+ }
290
+
291
+ // ---- options -------------------------------------------------------------
292
+
293
+ // optionStmt = "option" optionName "=" constant ";"
294
+ function optionFrom(el: Node): Record<string, OptionValue> {
295
+ const cst = child(el, 'constant')
296
+ if (!cst) return {}
297
+ const name = optionNameOf(el, cst).replace(/^option/, '')
298
+ return { [name]: constantValue(cst) }
299
+ }
300
+
301
+ // ---- services ------------------------------------------------------------
302
+
303
+ function buildService(n: Node): ServiceDescriptorProto {
304
+ const svc: ServiceDescriptorProto = { name: child(n, 'ident')?.src ?? '', method: [] }
305
+ for (const el of R(n).filter((k) => k.rule === 'serviceElement')) {
306
+ if (kw(el).startsWith('rpc')) svc.method.push(buildRpc(el))
307
+ }
308
+ return svc
309
+ }
310
+
311
+ // rpc ident "(" ["stream"] messageType ")" "returns" "(" ["stream"] messageType ")"
312
+ function buildRpc(el: Node): MethodDescriptorProto {
313
+ const ids = R(el).filter((k) => k.rule === 'ident')
314
+ const types = R(el).filter((k) => k.rule === 'messageType')
315
+ const flat = el.src.replace(/\s+/g, '')
316
+ const m: MethodDescriptorProto = {
317
+ name: ids[0] ? ids[0].src : '',
318
+ inputType: types[0] ? types[0].src : '',
319
+ outputType: types[1] ? types[1].src : '',
320
+ }
321
+ // Split request vs response on the `returns` keyword so a `(stream …)`
322
+ // is attributed to the right side even when in/out types are identical.
323
+ const ri = flat.indexOf('returns(')
324
+ const request = ri >= 0 ? flat.slice(0, ri) : flat
325
+ const response = ri >= 0 ? flat.slice(ri) : ''
326
+ if (/\(stream/.test(request)) m.clientStreaming = true
327
+ if (/\(stream/.test(response)) m.serverStreaming = true
328
+ return m
329
+ }
330
+
331
+ // ---- file ----------------------------------------------------------------
332
+
333
+ export function buildFile(proto: Node, version: ProtoVersion): FileDescriptorProto {
334
+ const file: FileDescriptorProto = {
335
+ dependency: [], publicDependency: [], weakDependency: [],
336
+ messageType: [], enumType: [], service: [], extension: [],
337
+ }
338
+ if (isEdition(version)) file.edition = 'EDITION_' + version
339
+ else file.syntax = version
340
+
341
+ for (const def of R(proto).filter((k) => k.rule === 'topLevelDef')) {
342
+ const k = kw(def)
343
+ if (k.startsWith('package')) {
344
+ file.package = child(def, 'fullIdent')?.src
345
+ } else if (k.startsWith('import')) {
346
+ const s = child(def, 'strLit')
347
+ if (s) {
348
+ const idx = file.dependency.length
349
+ file.dependency.push(unquote(s.src))
350
+ if (k.includes('public')) file.publicDependency.push(idx)
351
+ if (k.includes('weak')) file.weakDependency.push(idx)
352
+ }
353
+ } else if (k.startsWith('option')) {
354
+ file.options = { ...(file.options || {}), ...optionFrom(def) }
355
+ } else if (k.startsWith('export') || k.startsWith('local')) {
356
+ const m = child(def, 'message'); const e = child(def, 'enumDef')
357
+ if (m) file.messageType.push(buildMessage(m, version))
358
+ else if (e) file.enumType.push(buildEnum(e))
359
+ } else if (k.startsWith('message')) {
360
+ file.messageType.push(buildMessage(def, version))
361
+ } else if (k.startsWith('enum')) {
362
+ file.enumType.push(buildEnum(def))
363
+ } else if (k.startsWith('service')) {
364
+ file.service.push(buildService(def))
365
+ } else if (k.startsWith('extend')) {
366
+ addExtend(def, version, file.extension)
367
+ }
368
+ }
369
+ return file
370
+ }
@@ -0,0 +1,121 @@
1
+ /* Copyright (c) 2026 Richard Rodger and other contributors, MIT License */
2
+
3
+ // FileDescriptorProto-shaped output types. This mirrors the canonical
4
+ // `descriptor.proto` message set (the shape `protoc --descriptor_set_out`
5
+ // produces, in its JSON form): camelCase fields, enum values as their
6
+ // string names (`TYPE_INT32`, `LABEL_REPEATED`). Only fields the parser
7
+ // can populate from a single `.proto` source are included; cross-file
8
+ // type resolution is left to a later pass, so `typeName` is stored as
9
+ // written.
10
+
11
+ export type FieldLabel = 'LABEL_OPTIONAL' | 'LABEL_REQUIRED' | 'LABEL_REPEATED'
12
+
13
+ export type FieldType =
14
+ | 'TYPE_DOUBLE' | 'TYPE_FLOAT' | 'TYPE_INT64' | 'TYPE_UINT64'
15
+ | 'TYPE_INT32' | 'TYPE_FIXED64' | 'TYPE_FIXED32' | 'TYPE_BOOL'
16
+ | 'TYPE_STRING' | 'TYPE_GROUP' | 'TYPE_MESSAGE' | 'TYPE_BYTES'
17
+ | 'TYPE_UINT32' | 'TYPE_ENUM' | 'TYPE_SFIXED32' | 'TYPE_SFIXED64'
18
+ | 'TYPE_SINT32' | 'TYPE_SINT64'
19
+
20
+ export type OptionValue = string | number | boolean | { [k: string]: OptionValue }
21
+
22
+ export interface FieldDescriptorProto {
23
+ name: string
24
+ number: number
25
+ label?: FieldLabel
26
+ type?: FieldType
27
+ // Set for message/enum/group field types (resolution deferred).
28
+ typeName?: string
29
+ // proto3 explicit `optional` (synthesises a single-field oneof in protoc).
30
+ proto3Optional?: boolean
31
+ // The oneof this field belongs to, as an index into the message's
32
+ // `oneofDecl`.
33
+ oneofIndex?: number
34
+ options?: Record<string, OptionValue>
35
+ }
36
+
37
+ export interface EnumValueDescriptorProto {
38
+ name: string
39
+ number: number
40
+ options?: Record<string, OptionValue>
41
+ }
42
+
43
+ export interface EnumDescriptorProto {
44
+ name: string
45
+ value: EnumValueDescriptorProto[]
46
+ reservedRange?: { start: number; end: number }[]
47
+ reservedName?: string[]
48
+ options?: Record<string, OptionValue>
49
+ }
50
+
51
+ export interface OneofDescriptorProto {
52
+ name: string
53
+ options?: Record<string, OptionValue>
54
+ }
55
+
56
+ export interface DescriptorProto {
57
+ name: string
58
+ field: FieldDescriptorProto[]
59
+ nestedType: DescriptorProto[]
60
+ enumType: EnumDescriptorProto[]
61
+ oneofDecl: OneofDescriptorProto[]
62
+ extension: FieldDescriptorProto[]
63
+ extensionRange?: { start: number; end: number }[]
64
+ reservedRange?: { start: number; end: number }[]
65
+ reservedName?: string[]
66
+ options?: Record<string, OptionValue>
67
+ }
68
+
69
+ export interface MethodDescriptorProto {
70
+ name: string
71
+ inputType: string
72
+ outputType: string
73
+ clientStreaming?: boolean
74
+ serverStreaming?: boolean
75
+ options?: Record<string, OptionValue>
76
+ }
77
+
78
+ export interface ServiceDescriptorProto {
79
+ name: string
80
+ method: MethodDescriptorProto[]
81
+ options?: Record<string, OptionValue>
82
+ }
83
+
84
+ export interface FileDescriptorProto {
85
+ // File name is not present in source; callers may set it.
86
+ name?: string
87
+ package?: string
88
+ dependency: string[]
89
+ publicDependency: number[]
90
+ weakDependency: number[]
91
+ messageType: DescriptorProto[]
92
+ enumType: EnumDescriptorProto[]
93
+ service: ServiceDescriptorProto[]
94
+ extension: FieldDescriptorProto[]
95
+ options?: Record<string, OptionValue>
96
+ // 'proto2' | 'proto3' for syntax files; absent for edition files.
97
+ syntax?: string
98
+ // 'EDITION_2023' | 'EDITION_2024' for edition files; absent otherwise.
99
+ edition?: string
100
+ }
101
+
102
+ // Scalar protobuf types -> FieldDescriptorProto.type. A field whose type
103
+ // is not in this table is a message/enum/group reference (resolution
104
+ // deferred) and gets a `typeName` instead.
105
+ export const SCALAR_TYPES: Record<string, FieldType> = {
106
+ double: 'TYPE_DOUBLE',
107
+ float: 'TYPE_FLOAT',
108
+ int32: 'TYPE_INT32',
109
+ int64: 'TYPE_INT64',
110
+ uint32: 'TYPE_UINT32',
111
+ uint64: 'TYPE_UINT64',
112
+ sint32: 'TYPE_SINT32',
113
+ sint64: 'TYPE_SINT64',
114
+ fixed32: 'TYPE_FIXED32',
115
+ fixed64: 'TYPE_FIXED64',
116
+ sfixed32: 'TYPE_SFIXED32',
117
+ sfixed64: 'TYPE_SFIXED64',
118
+ bool: 'TYPE_BOOL',
119
+ string: 'TYPE_STRING',
120
+ bytes: 'TYPE_BYTES',
121
+ }
@@ -0,0 +1,53 @@
1
+ /* Copyright (c) 2026 Richard Rodger and other contributors, MIT License */
2
+
3
+ // Protobuf version detection and option reconciliation.
4
+
5
+ export type ProtoVersion = 'proto2' | 'proto3' | '2023' | '2024'
6
+
7
+ const SYNTAX_VERSIONS = new Set(['proto2', 'proto3'])
8
+ const EDITION_VERSIONS = new Set(['2023', '2024'])
9
+
10
+ // Pull the declared version out of a `syntaxOrEdition` CST node, or null
11
+ // if the file has no leading `syntax`/`edition` declaration. The node's
12
+ // `src` is whitespace-stripped, e.g. `syntax="proto3";` or `edition="2023";`.
13
+ export function declaredVersion(syntaxNode: any): ProtoVersion | null {
14
+ if (!syntaxNode || 'string' !== typeof syntaxNode.src) return null
15
+ const m = syntaxNode.src.match(/^(syntax|edition)=["']([^"']+)["']/)
16
+ if (!m) return null
17
+ const value = m[2]
18
+ if (SYNTAX_VERSIONS.has(value) || EDITION_VERSIONS.has(value)) {
19
+ return value as ProtoVersion
20
+ }
21
+ throw new Error(`proto: unknown ${m[1]} version "${value}"`)
22
+ }
23
+
24
+ // Reconcile the version declared in the source with the version supplied
25
+ // via the plugin option. With `reconcile` true (the default) a mismatch is
26
+ // an error; otherwise the declaration wins when present. Falls back to
27
+ // proto2 (protoc's default for a file with no declaration and no option).
28
+ export function resolveVersion(
29
+ declared: ProtoVersion | null,
30
+ option: ProtoVersion | null,
31
+ reconcile: boolean,
32
+ ): ProtoVersion {
33
+ if (null != declared && null != option && declared !== option) {
34
+ if (reconcile) {
35
+ throw new Error(
36
+ `proto: version mismatch — option "${option}" but the file ` +
37
+ `declares "${declared}". Set reconcile:false to let the file win.`,
38
+ )
39
+ }
40
+ return declared
41
+ }
42
+ return declared ?? option ?? 'proto2'
43
+ }
44
+
45
+ export function isEdition(v: ProtoVersion): boolean {
46
+ return EDITION_VERSIONS.has(v)
47
+ }
48
+
49
+ // FileDescriptorProto records syntax files via `syntax` and edition files
50
+ // via `edition` (as `EDITION_2023` / `EDITION_2024`).
51
+ export function editionEnum(v: ProtoVersion): string {
52
+ return 'EDITION_' + v
53
+ }
package/src/grammar.ts ADDED
@@ -0,0 +1,102 @@
1
+ /* Copyright (c) 2026 Richard Rodger and other contributors, MIT License */
2
+
3
+ // GENERATED by embed-grammar.js from proto-grammar/*.abnf — do not edit.
4
+ // Run `npm run embed` after changing any .abnf file.
5
+
6
+ export const grammarText = `
7
+ ; ===== common.abnf =====
8
+ ; @tabnas/proto — Protocol Buffers .proto IDL, common (union base).
9
+ ; Structure only; lexical atoms are the engine's whole-word tokens:
10
+ ; TX = identifier NR = number ST = string VL = true/false/null
11
+ ; Whitespace and // and /* */ comments are ignored by the lexer.
12
+ ; The grammar is a permissive superset of proto2 / proto3 / editions;
13
+ ; version-specific legality is enforced in the descriptor walk.
14
+
15
+ proto = [ syntaxOrEdition ] *topLevelDef
16
+
17
+ syntaxOrEdition = "syntax" "=" strLit ";"
18
+
19
+ topLevelDef = importStmt / packageStmt / optionStmt / message / enumDef
20
+ / service / extendStmt / emptyStmt
21
+
22
+ importStmt = "import" [ "weak" / "public" ] strLit ";"
23
+ packageStmt = "package" fullIdent ";"
24
+
25
+ optionStmt = "option" optionName "=" constant ";"
26
+ optionName = optionNamePart *( "." optionNamePart )
27
+ optionNamePart = ident / "(" [ "." ] fullIdent ")"
28
+
29
+ message = "message" ident messageBody
30
+ messageBody = "{" *messageElement "}"
31
+ messageElement = field / enumDef / message / optionStmt / oneof / mapField
32
+ / extendStmt / extensions / reserved / emptyStmt
33
+
34
+ field = [ label ] fieldType ident "=" fieldNumber [ fieldOptions ] ";"
35
+ label = "required" / "optional" / "repeated"
36
+ fieldType = messageType
37
+ fieldNumber = NR
38
+ fieldOptions = "[" fieldOption *( "," fieldOption ) "]"
39
+ fieldOption = optionName "=" constant
40
+
41
+ oneof = "oneof" ident "{" *oneofElement "}"
42
+ oneofElement = optionStmt / oneofField / emptyStmt
43
+ oneofField = fieldType ident "=" fieldNumber [ fieldOptions ] ";"
44
+
45
+ mapField = "map" "<" fieldType "," fieldType ">" ident "=" fieldNumber
46
+ [ fieldOptions ] ";"
47
+
48
+ enumDef = "enum" ident "{" *enumElement "}"
49
+ enumElement = optionStmt / enumField / reserved / emptyStmt
50
+ enumField = ident "=" [ "-" ] fieldNumber [ fieldOptions ] ";"
51
+
52
+ service = "service" ident "{" *serviceElement "}"
53
+ serviceElement = optionStmt / rpc / emptyStmt
54
+ rpc = "rpc" ident "(" [ "stream" ] messageType ")"
55
+ "returns" "(" [ "stream" ] messageType ")"
56
+ ( ";" / "{" *( optionStmt / emptyStmt ) "}" )
57
+
58
+ extendStmt = "extend" messageType "{" *( field / emptyStmt ) "}"
59
+ extensions = "extensions" ranges [ fieldOptions ] ";"
60
+ reserved = "reserved" ( ranges / fieldNames ) ";"
61
+ ranges = range *( "," range )
62
+ range = NR [ "to" ( NR / "max" ) ]
63
+ fieldNames = strLit *( "," strLit )
64
+
65
+ constant = boolLit / fullIdent / signedNumber / strLit / messageValue
66
+ signedNumber = [ "-" / "+" ] NR
67
+ boolLit = VL
68
+ messageValue = "{" *messageValueEntry "}"
69
+ messageValueEntry = ident [ ":" ] constant [ "," / ";" ]
70
+
71
+ messageType = [ "." ] fullIdent
72
+ fullIdent = ident *( "." ident )
73
+ ident = TX
74
+ strLit = ST
75
+ emptyStmt = ";"
76
+
77
+ ; ===== proto2.abnf =====
78
+ ; proto2 deltas: groups are proto2-only (deprecated). Labels (required/
79
+ ; optional/repeated), extend, and extensions live in common (the permissive
80
+ ; union); the descriptor walk enforces what each version actually allows.
81
+ messageElement =/ groupField
82
+ groupField = [ label ] "group" ident "=" fieldNumber messageBody
83
+
84
+ ; ===== proto3.abnf =====
85
+ ; proto3 deltas: none. proto3's surface syntax is the common base
86
+ ; (implicit label via the optional [ label ], plus the "optional" keyword).
87
+ ; Version legality (no required, no group) is enforced in the walk.
88
+
89
+ ; ===== edition-2023.abnf =====
90
+ ; edition 2023 deltas: the file may open with an \`edition = "2023";\`
91
+ ; declaration instead of \`syntax = ...\`.
92
+ syntaxOrEdition =/ editionDecl
93
+ editionDecl = "edition" "=" strLit ";"
94
+
95
+ ; ===== edition-2024.abnf =====
96
+ ; edition 2024 deltas: \`import option "...";\` and symbol visibility
97
+ ; (export / local) on message and enum declarations.
98
+ importStmt =/ "import" "option" strLit ";"
99
+ topLevelDef =/ symbolVisibility message / symbolVisibility enumDef
100
+ messageElement =/ symbolVisibility message / symbolVisibility enumDef
101
+ symbolVisibility = "export" / "local"
102
+ `