@tabnas/proto 0.2.3 → 0.4.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.
@@ -10,15 +10,18 @@
10
10
  import {
11
11
  FileDescriptorProto, DescriptorProto, FieldDescriptorProto,
12
12
  EnumDescriptorProto, ServiceDescriptorProto, MethodDescriptorProto,
13
- OneofDescriptorProto, OptionValue, FieldLabel, SCALAR_TYPES,
13
+ DescriptorRange, SymbolVisibility, OptionValue, FieldLabel, SCALAR_TYPES,
14
+ MAX_FIELD_NUMBER_END, MAX_MESSAGE_SET_END, MAX_ENUM_NUMBER,
14
15
  } from './descriptor'
15
16
  import { ProtoVersion, isEdition } from './detect-version'
16
17
 
17
18
  type Node = { rule?: string; src: string; kids?: Node[] }
18
19
 
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)
20
+ // Children that are real rule nodes (terminals fold into `src`). An empty
21
+ // `.proto` is a legal (if useless) file and parses to no node at all, so
22
+ // tolerate a missing node here rather than crash at the root.
23
+ function R(n: Node | undefined | null): Node[] {
24
+ return ((n && n.kids) || []).filter((k) => k && k.rule)
22
25
  }
23
26
 
24
27
  // The keyword(s) consumed before this node's first child — the part of
@@ -51,30 +54,65 @@ function constantValue(n: Node): OptionValue {
51
54
  const num = Number(s.replace(/^\+/, ''))
52
55
  if (!Number.isNaN(num)) return num
53
56
  }
54
- return s // identifier (enum value name, inf, nan, …) kept verbatim
57
+ return s // identifier (enum value name, inf, -nan, …) kept verbatim
55
58
  }
56
59
 
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.
60
+ // The option name in an `optionName "=" constant` statement.
61
+ //
62
+ // `optionStmt` keeps `optionName` as a child node, so use it when present.
63
+ // Inside `fieldOption` abnf inlines the leading `optionNamePart`, so there
64
+ // the name has to be read out of `src` — as everything BEFORE the trailing
65
+ // `"=" constant`. Anchoring at the end matters: `option (file_opt1) = 1;`
66
+ // would otherwise find the value `1` inside the name `file_opt1`.
61
67
  function optionNameOf(stmt: Node, valueNode: Node | undefined): string {
68
+ const named = child(stmt, 'optionName')
69
+ if (named) return named.src
62
70
  if (!valueNode) return ''
63
- const i = stmt.src.indexOf(valueNode.src)
64
- return (i <= 0 ? '' : stmt.src.slice(0, i)).replace(/=$/, '')
71
+ const s = stmt.src.replace(/;$/, '')
72
+ const tail = '=' + valueNode.src
73
+ if (s.endsWith(tail)) return s.slice(0, s.length - tail.length)
74
+ const i = s.indexOf(valueNode.src)
75
+ return i <= 0 ? '' : s.slice(0, i).replace(/=$/, '')
76
+ }
77
+
78
+ // The `json_name` and `default` field options are pseudo-options: protoc
79
+ // lifts them out of the option set into FieldDescriptorProto fields.
80
+ type PseudoOptions = {
81
+ options?: Record<string, OptionValue>
82
+ jsonName?: string
83
+ defaultValue?: string
65
84
  }
66
85
 
67
86
  // fieldOptions = "[" fieldOption *( "," fieldOption ) "]"
68
87
  // fieldOption = optionName "=" constant
69
- function readFieldOptions(opts: Node | undefined): Record<string, OptionValue> | undefined {
70
- if (!opts) return undefined
71
- const out: Record<string, OptionValue> = {}
88
+ function readFieldOptions(opts: Node | undefined): PseudoOptions {
89
+ const out: PseudoOptions = {}
90
+ if (!opts) return out
91
+ const map: Record<string, OptionValue> = {}
72
92
  for (const fo of R(opts)) {
73
93
  const cst = child(fo, 'constant')
74
94
  const name = optionNameOf(fo, cst)
75
- if (cst && name) out[name] = constantValue(cst)
95
+ if (!cst || !name) continue
96
+ if ('json_name' === name) { out.jsonName = String(unquote(cst.src)); continue }
97
+ if ('default' === name) { out.defaultValue = unquote(cst.src); continue }
98
+ map[name] = constantValue(cst)
76
99
  }
77
- return Object.keys(out).length ? out : undefined
100
+ if (Object.keys(map).length) out.options = map
101
+ return out
102
+ }
103
+
104
+ // The plain (non-pseudo) option map, for the places that cannot carry
105
+ // `json_name` / `default` — extension ranges, enum values.
106
+ function plainOptions(opts: Node | undefined): Record<string, OptionValue> | undefined {
107
+ return readFieldOptions(opts).options
108
+ }
109
+
110
+ // optionStmt = "option" optionName "=" constant ";"
111
+ function optionFrom(el: Node): Record<string, OptionValue> {
112
+ const cst = child(el, 'constant')
113
+ if (!cst) return {}
114
+ const name = optionNameOf(el, cst)
115
+ return name ? { [name]: constantValue(cst) } : {}
78
116
  }
79
117
 
80
118
  // ---- fields --------------------------------------------------------------
@@ -99,8 +137,11 @@ function fieldTypeName(typeText: string): Pick<FieldDescriptorProto, 'type' | 't
99
137
  const bare = typeText.replace(/^\./, '')
100
138
  const scalar = SCALAR_TYPES[bare]
101
139
  if (scalar) return { type: scalar }
102
- // Message or enum reference; resolution deferred, store as written.
103
- return { type: 'TYPE_MESSAGE', typeName: typeText }
140
+ // A named reference: could be a message OR an enum, and telling them
141
+ // apart needs symbol resolution this parser deliberately does not do.
142
+ // protoc leaves `type` unset here too, and only fills it in once the
143
+ // name resolves — so record `typeName` as written and nothing else.
144
+ return { typeName: typeText }
104
145
  }
105
146
 
106
147
  // A normal field: [label] fieldType ident "=" fieldNumber [fieldOptions].
@@ -113,12 +154,18 @@ function typeNodeOf(n: Node): Node | undefined {
113
154
  return child(n, 'fieldType') ?? child(n, 'messageType') ?? child(n, 'fullIdent')
114
155
  }
115
156
 
157
+ function applyFieldOptions(f: FieldDescriptorProto, opts: Node | undefined): void {
158
+ const po = readFieldOptions(opts)
159
+ if (undefined !== po.jsonName) f.jsonName = po.jsonName
160
+ if (undefined !== po.defaultValue) f.defaultValue = po.defaultValue
161
+ if (po.options) f.options = po.options
162
+ }
163
+
116
164
  function buildField(n: Node, version: ProtoVersion): FieldDescriptorProto {
117
165
  const label = child(n, 'label')
118
166
  const type = typeNodeOf(n)
119
167
  const name = child(n, 'ident')
120
168
  const number = child(n, 'fieldNumber')
121
- const opts = child(n, 'fieldOptions')
122
169
 
123
170
  const f: FieldDescriptorProto = {
124
171
  name: name ? name.src : '',
@@ -126,11 +173,64 @@ function buildField(n: Node, version: ProtoVersion): FieldDescriptorProto {
126
173
  ...fieldLabel(label, version),
127
174
  ...fieldTypeName(type ? type.src : ''),
128
175
  }
129
- const fo = readFieldOptions(opts)
130
- if (fo) f.options = fo
176
+ applyFieldOptions(f, child(n, 'fieldOptions'))
177
+ return f
178
+ }
179
+
180
+ // A group is a field plus an implicit nested message. protoc lowercases the
181
+ // field name and keeps the declared name for the message and for `typeName`.
182
+ // `optional group TestGroup = 1 { … }`
183
+ // -> field { name: 'testgroup', type: TYPE_GROUP, typeName: 'TestGroup' }
184
+ // -> nestedType { name: 'TestGroup', … }
185
+ function buildGroup(
186
+ n: Node, version: ProtoVersion, into: DescriptorProto,
187
+ ): FieldDescriptorProto {
188
+ const groupName = child(n, 'ident')?.src ?? ''
189
+ const number = child(n, 'fieldNumber')
190
+ into.nestedType.push(messageFromBody(groupName, child(n, 'messageBody'), version))
191
+
192
+ const f: FieldDescriptorProto = {
193
+ name: groupName.toLowerCase(),
194
+ number: number ? Number(number.src) : 0,
195
+ ...fieldLabel(child(n, 'label'), version),
196
+ type: 'TYPE_GROUP',
197
+ typeName: groupName,
198
+ }
199
+ applyFieldOptions(f, child(n, 'fieldOptions'))
131
200
  return f
132
201
  }
133
202
 
203
+ // A group is the one construct with BOTH a field number and a message body;
204
+ // `message` has a body but no number, `map`/`field` a number but no body.
205
+ function isGroup(n: Node): boolean {
206
+ return !!child(n, 'fieldNumber') && !!child(n, 'messageBody')
207
+ }
208
+
209
+ // The subset of an option map whose names are rooted at `features`.
210
+ function features(
211
+ opts: Record<string, OptionValue> | undefined,
212
+ ): Record<string, OptionValue> | undefined {
213
+ if (!opts) return undefined
214
+ const out: Record<string, OptionValue> = {}
215
+ for (const [k, v] of Object.entries(opts)) {
216
+ if ('features' === k || k.startsWith('features.')) out[k] = v
217
+ }
218
+ return Object.keys(out).length ? out : undefined
219
+ }
220
+
221
+ // protoc's map-entry name: strip `_`, upper-case the letter that follows
222
+ // (and the first letter), then append `Entry`. `map_field` -> `MapFieldEntry`.
223
+ function mapEntryName(fieldName: string): string {
224
+ let out = ''
225
+ let capNext = true
226
+ for (const ch of fieldName) {
227
+ if ('_' === ch) { capNext = true; continue }
228
+ out += capNext && 'a' <= ch && ch <= 'z' ? ch.toUpperCase() : ch
229
+ capNext = false
230
+ }
231
+ return out + 'Entry'
232
+ }
233
+
134
234
  // map<K,V> name = N; -> a repeated message field whose type is a
135
235
  // synthesised nested `<Name>Entry` message with mapEntry=true.
136
236
  function buildMapField(
@@ -142,15 +242,15 @@ function buildMapField(
142
242
  const keyText = types[0] ? types[0].src : ''
143
243
  const valText = types[1] ? types[1].src : ''
144
244
  const fname = name ? name.src : ''
145
- const entryName = fname.replace(/(^|_)([a-z])/g, (_, a, b) => a + b.toUpperCase())
146
- + 'Entry'
245
+ const entryName = mapEntryName(fname)
147
246
 
247
+ const key: FieldDescriptorProto =
248
+ { name: 'key', number: 1, label: 'LABEL_OPTIONAL', ...fieldTypeName(keyText) }
249
+ const value: FieldDescriptorProto =
250
+ { name: 'value', number: 2, label: 'LABEL_OPTIONAL', ...fieldTypeName(valText) }
148
251
  const entry: DescriptorProto = {
149
252
  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
- ],
253
+ field: [key, value],
154
254
  nestedType: [], enumType: [], oneofDecl: [], extension: [],
155
255
  options: { mapEntry: true },
156
256
  }
@@ -160,11 +260,14 @@ function buildMapField(
160
260
  name: fname,
161
261
  number: number ? Number(number.src) : 0,
162
262
  label: 'LABEL_REPEATED',
163
- type: 'TYPE_MESSAGE',
164
263
  typeName: entryName,
165
264
  }
166
- const fo = readFieldOptions(child(n, 'fieldOptions'))
167
- if (fo) f.options = fo
265
+ applyFieldOptions(f, child(n, 'fieldOptions'))
266
+
267
+ // `features` on a map field govern the synthesised entry's key and value
268
+ // fields too, so protoc copies them down. Nothing else is propagated.
269
+ const feat = features(f.options)
270
+ if (feat) { key.options = { ...feat }; value.options = { ...feat } }
168
271
  return f
169
272
  }
170
273
 
@@ -175,18 +278,22 @@ function buildEnum(n: Node): EnumDescriptorProto {
175
278
  for (const el of R(n).filter((k) => k.rule === 'enumElement')) {
176
279
  const k = kw(el)
177
280
  if (k.startsWith('reserved')) {
178
- addReserved(el, e)
281
+ // Enum reserved ranges are INCLUSIVE and span the whole int32 space.
282
+ addReserved(el, e, { exclusive: false, max: MAX_ENUM_NUMBER })
179
283
  continue
180
284
  }
181
285
  if (k.startsWith('option')) {
182
- continue // enum-level option; folded into options below if needed
286
+ e.options = { ...(e.options || {}), ...optionFrom(el) }
287
+ continue
183
288
  }
184
289
  // enumField: ident "=" ["-"] fieldNumber -> name is the kw before "="
185
- const name = kw(el).replace(/=.*$/, '')
290
+ const name = k.replace(/=.*$/, '')
186
291
  const num = child(el, 'fieldNumber')
187
292
  if (name && num) {
188
293
  const neg = /=-/.test(el.src.replace(/\s+/g, ''))
189
- e.value.push({ name, number: (neg ? -1 : 1) * Number(num.src) })
294
+ const v = { name, number: (neg ? -1 : 1) * Number(num.src) }
295
+ const vo = plainOptions(child(el, 'fieldOptions'))
296
+ e.value.push(vo ? { ...v, options: vo } : v)
190
297
  }
191
298
  }
192
299
  return e
@@ -194,31 +301,51 @@ function buildEnum(n: Node): EnumDescriptorProto {
194
301
 
195
302
  // ---- reserved / extensions ranges ----------------------------------------
196
303
 
304
+ type RangeOpts = { exclusive: boolean; max: number }
305
+
197
306
  // `ranges = range *( "," range )`. The leading `range` is inlined into
198
307
  // `ranges.src`, so parse the (whitespace-stripped) text rather than kids.
199
- function ranges(rangesNode: Node | undefined): { start: number; end: number }[] {
308
+ function ranges(rangesNode: Node | undefined, ro: RangeOpts): DescriptorRange[] {
200
309
  if (!rangesNode) return []
201
- const out: { start: number; end: number }[] = []
310
+ const out: DescriptorRange[] = []
202
311
  for (const part of rangesNode.src.replace(/\s+/g, '').split(',')) {
203
312
  const m = part.match(/^(-?\d+)(?:to(-?\d+|max))?$/)
204
313
  if (!m) continue
205
314
  const start = Number(m[1])
206
- const end = !m[2] ? start : (m[2] === 'max' ? 536870911 : Number(m[2]))
315
+ let end: number
316
+ if (!m[2]) end = ro.exclusive ? start + 1 : start
317
+ else if ('max' === m[2]) end = ro.max
318
+ else end = Number(m[2]) + (ro.exclusive ? 1 : 0)
207
319
  out.push({ start, end })
208
320
  }
209
321
  return out
210
322
  }
211
323
 
212
- function addReserved(n: Node, target: { reservedRange?: any[]; reservedName?: string[] }): void {
324
+ // The reserved-name list. Both the leading `strLit`/`ident` and (for a
325
+ // single-item list) the whole list can be inlined into `src`, so read the
326
+ // names out of the statement text; whole-word tokens make that unambiguous.
327
+ function reservedNames(n: Node): string[] {
328
+ const body = n.src.replace(/^reserved/, '').replace(/;$/, '')
329
+ const re = /"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'|([A-Za-z_][A-Za-z0-9_]*)/g
330
+ const out: string[] = []
331
+ let m: RegExpExecArray | null
332
+ while (null !== (m = re.exec(body))) out.push(m[1] ?? m[2] ?? m[3])
333
+ return out
334
+ }
335
+
336
+ function addReserved(
337
+ n: Node,
338
+ target: { reservedRange?: DescriptorRange[]; reservedName?: string[] },
339
+ ro: RangeOpts,
340
+ ): void {
213
341
  const rn = child(n, 'ranges')
214
342
  if (rn) {
215
- (target.reservedRange = target.reservedRange || []).push(...ranges(rn))
343
+ (target.reservedRange = target.reservedRange || []).push(...ranges(rn, ro))
216
344
  return
217
345
  }
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)
346
+ const names = reservedNames(n)
347
+ if (names.length) {
348
+ (target.reservedName = target.reservedName || []).push(...names)
222
349
  }
223
350
  }
224
351
 
@@ -227,39 +354,87 @@ function addReserved(n: Node, target: { reservedRange?: any[]; reservedName?: st
227
354
  function buildMessage(n: Node, version: ProtoVersion): DescriptorProto {
228
355
  // `n` is a dispatch node whose `message` alt was inlined: kids are
229
356
  // [ident, messageBody-children…] or [ident] then messageBody.
357
+ return messageFromBody(
358
+ child(n, 'ident')?.src ?? '', child(n, 'messageBody'), version)
359
+ }
360
+
361
+ function messageFromBody(
362
+ name: string, body: Node | undefined, version: ProtoVersion,
363
+ ): DescriptorProto {
230
364
  const msg: DescriptorProto = {
231
- name: child(n, 'ident')?.src ?? '',
365
+ name,
232
366
  field: [], nestedType: [], enumType: [], oneofDecl: [], extension: [],
233
367
  }
234
- const body = child(n, 'messageBody')
235
368
  const elements = body ? R(body).filter((k) => k.rule === 'messageElement') : []
236
- for (const el of elements) addMessageElement(el, version, msg)
369
+
370
+ // Options first: `message_set_wire_format` changes what `to max` means in
371
+ // an extension/reserved range, and protoc applies it wherever the option
372
+ // sits in the body.
373
+ for (const el of elements) {
374
+ if (isOptionStmt(el)) msg.options = { ...(msg.options || {}), ...optionFrom(el) }
375
+ }
376
+ const ro: RangeOpts = {
377
+ exclusive: true,
378
+ max: true === msg.options?.message_set_wire_format
379
+ ? MAX_MESSAGE_SET_END : MAX_FIELD_NUMBER_END,
380
+ }
381
+
382
+ for (const el of elements) {
383
+ if (!isOptionStmt(el)) addMessageElement(el, version, msg, ro)
384
+ }
385
+ generateSyntheticOneofs(msg)
237
386
  return msg
238
387
  }
239
388
 
240
- function addMessageElement(el: Node, version: ProtoVersion, msg: DescriptorProto): void {
389
+ // An `option` statement, as opposed to an `optional`-labelled field (whose
390
+ // `kw` is empty because the `label` node starts the src).
391
+ function isOptionStmt(el: Node): boolean {
392
+ return kw(el).startsWith('option')
393
+ }
394
+
395
+ // protoc synthesises a single-field oneof for every proto3 explicit
396
+ // `optional` field, appended after the declared oneofs. The name is the
397
+ // field name prefixed with `_`, then prefixed with `X` until unique.
398
+ function generateSyntheticOneofs(msg: DescriptorProto): void {
399
+ const names = new Set<string>()
400
+ for (const f of msg.field) names.add(f.name)
401
+ for (const o of msg.oneofDecl) names.add(o.name)
402
+ for (const f of msg.field) {
403
+ if (true !== f.proto3Optional || undefined !== f.oneofIndex) continue
404
+ let oneofName = '_' + f.name
405
+ while (names.has(oneofName)) oneofName = 'X' + oneofName
406
+ names.add(oneofName)
407
+ f.oneofIndex = msg.oneofDecl.length
408
+ msg.oneofDecl.push({ name: oneofName })
409
+ }
410
+ }
411
+
412
+ function addMessageElement(
413
+ el: Node, version: ProtoVersion, msg: DescriptorProto, ro: RangeOpts,
414
+ ): void {
241
415
  const k = kw(el)
242
416
  const first = R(el)[0]
243
417
  if (k.startsWith('map<')) { msg.field.push(buildMapField(el, version, msg)); return }
244
418
  if (k.startsWith('oneof')) { addOneof(el, version, msg); return }
245
419
  if (k.startsWith('export') || k.startsWith('local')) {
246
420
  // 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))
421
+ addVisible(el, k, version, msg.nestedType, msg.enumType)
250
422
  return
251
423
  }
424
+ if (isGroup(el)) { msg.field.push(buildGroup(el, version, msg)); return }
252
425
  if (k.startsWith('message')) { msg.nestedType.push(buildMessage(el, version)); return }
253
426
  if (k.startsWith('enum')) { msg.enumType.push(buildEnum(el)); return }
254
- if (k.startsWith('reserved')) { addReserved(el, msg); return }
427
+ if (k.startsWith('reserved')) { addReserved(el, msg, ro); return }
255
428
  if (k.startsWith('extensions')) {
256
- (msg.extensionRange = msg.extensionRange || []).push(...ranges(child(el, 'ranges')))
429
+ const opts = plainOptions(child(el, 'fieldOptions'))
430
+ const rs = ranges(child(el, 'ranges'), ro)
431
+ // A compound `extensions 2, 9 to 11 [(i) = 5];` puts the options on
432
+ // every range, as protoc does.
433
+ for (const r of rs) if (opts) r.options = { ...opts }
434
+ ;(msg.extensionRange = msg.extensionRange || []).push(...rs)
257
435
  return
258
436
  }
259
437
  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
438
  if (';' === el.src) return // emptyStmt
264
439
  // No keyword and a fieldType/label lead => a field.
265
440
  if (first && (first.rule === 'fieldType' || first.rule === 'label')) {
@@ -267,6 +442,20 @@ function addMessageElement(el: Node, version: ProtoVersion, msg: DescriptorProto
267
442
  }
268
443
  }
269
444
 
445
+ // edition 2024 `export` / `local` on a message or enum declaration. The
446
+ // wrapped `message` / `enumDef` stays a child node instead of inlining.
447
+ function addVisible(
448
+ el: Node, k: string, version: ProtoVersion,
449
+ messages: DescriptorProto[], enums: EnumDescriptorProto[],
450
+ ): void {
451
+ const vis: SymbolVisibility =
452
+ k.startsWith('export') ? 'VISIBILITY_EXPORT' : 'VISIBILITY_LOCAL'
453
+ const m = child(el, 'message')
454
+ const e = child(el, 'enumDef')
455
+ if (m) messages.push({ ...buildMessage(m, version), visibility: vis })
456
+ else if (e) enums.push({ ...buildEnum(e), visibility: vis })
457
+ }
458
+
270
459
  function addOneof(el: Node, version: ProtoVersion, msg: DescriptorProto): void {
271
460
  const name = child(el, 'ident')?.src ?? ''
272
461
  const index = msg.oneofDecl.length
@@ -274,7 +463,7 @@ function addOneof(el: Node, version: ProtoVersion, msg: DescriptorProto): void {
274
463
  for (const of of R(el).filter((k) => k.rule === 'oneofElement')) {
275
464
  if (kw(of).startsWith('option')) continue
276
465
  if (';' === of.src) continue
277
- const f = buildField(of, version)
466
+ const f = isGroup(of) ? buildGroup(of, version, msg) : buildField(of, version)
278
467
  f.oneofIndex = index
279
468
  delete f.proto3Optional // explicit oneof members aren't proto3-optional
280
469
  msg.field.push(f)
@@ -283,27 +472,21 @@ function addOneof(el: Node, version: ProtoVersion, msg: DescriptorProto): void {
283
472
 
284
473
  function addExtend(el: Node, version: ProtoVersion, into: FieldDescriptorProto[]): void {
285
474
  // extend messageType "{" *field "}" — fields inline as messageElement-like.
475
+ const extendee = child(el, 'messageType')?.src
286
476
  for (const f of R(el).filter((k) => k.rule === 'field' || k.rule === 'messageElement')) {
287
- into.push(buildField(f, version))
477
+ const fd = buildField(f, version)
478
+ if (extendee) fd.extendee = extendee
479
+ into.push(fd)
288
480
  }
289
481
  }
290
482
 
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
483
  // ---- services ------------------------------------------------------------
302
484
 
303
485
  function buildService(n: Node): ServiceDescriptorProto {
304
486
  const svc: ServiceDescriptorProto = { name: child(n, 'ident')?.src ?? '', method: [] }
305
487
  for (const el of R(n).filter((k) => k.rule === 'serviceElement')) {
306
488
  if (kw(el).startsWith('rpc')) svc.method.push(buildRpc(el))
489
+ else if (isOptionStmt(el)) svc.options = { ...(svc.options || {}), ...optionFrom(el) }
307
490
  }
308
491
  return svc
309
492
  }
@@ -325,6 +508,9 @@ function buildRpc(el: Node): MethodDescriptorProto {
325
508
  const response = ri >= 0 ? flat.slice(ri) : ''
326
509
  if (/\(stream/.test(request)) m.clientStreaming = true
327
510
  if (/\(stream/.test(response)) m.serverStreaming = true
511
+ for (const o of R(el).filter((k) => k.rule === 'optionStmt')) {
512
+ m.options = { ...(m.options || {}), ...optionFrom(o) }
513
+ }
328
514
  return m
329
515
  }
330
516
 
@@ -335,8 +521,12 @@ export function buildFile(proto: Node, version: ProtoVersion): FileDescriptorPro
335
521
  dependency: [], publicDependency: [], weakDependency: [],
336
522
  messageType: [], enumType: [], service: [], extension: [],
337
523
  }
338
- if (isEdition(version)) file.edition = 'EDITION_' + version
339
- else file.syntax = version
524
+ if (isEdition(version)) {
525
+ file.edition = 'EDITION_' + version
526
+ file.syntax = 'editions'
527
+ } else {
528
+ file.syntax = version
529
+ }
340
530
 
341
531
  for (const def of R(proto).filter((k) => k.rule === 'topLevelDef')) {
342
532
  const k = kw(def)
@@ -345,17 +535,20 @@ export function buildFile(proto: Node, version: ProtoVersion): FileDescriptorPro
345
535
  } else if (k.startsWith('import')) {
346
536
  const s = child(def, 'strLit')
347
537
  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)
538
+ // `import option "x";` (edition 2024) is a separate dependency list.
539
+ if (k.includes('option')) {
540
+ (file.optionDependency = file.optionDependency || []).push(unquote(s.src))
541
+ } else {
542
+ const idx = file.dependency.length
543
+ file.dependency.push(unquote(s.src))
544
+ if (k.includes('public')) file.publicDependency.push(idx)
545
+ if (k.includes('weak')) file.weakDependency.push(idx)
546
+ }
352
547
  }
353
- } else if (k.startsWith('option')) {
548
+ } else if (isOptionStmt(def)) {
354
549
  file.options = { ...(file.options || {}), ...optionFrom(def) }
355
550
  } 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))
551
+ addVisible(def, k, version, file.messageType, file.enumType)
359
552
  } else if (k.startsWith('message')) {
360
553
  file.messageType.push(buildMessage(def, version))
361
554
  } else if (k.startsWith('enum')) {
package/src/descriptor.ts CHANGED
@@ -19,6 +19,17 @@ export type FieldType =
19
19
 
20
20
  export type OptionValue = string | number | boolean | { [k: string]: OptionValue }
21
21
 
22
+ // Symbol visibility, an edition-2024 feature (`export` / `local`).
23
+ export type SymbolVisibility = 'VISIBILITY_EXPORT' | 'VISIBILITY_LOCAL'
24
+
25
+ // A numeric range. `end` is EXCLUSIVE for message extension/reserved ranges
26
+ // and INCLUSIVE for enum reserved ranges — the same asymmetry protoc has.
27
+ export interface DescriptorRange {
28
+ start: number
29
+ end: number
30
+ options?: Record<string, OptionValue>
31
+ }
32
+
22
33
  export interface FieldDescriptorProto {
23
34
  name: string
24
35
  number: number
@@ -26,6 +37,13 @@ export interface FieldDescriptorProto {
26
37
  type?: FieldType
27
38
  // Set for message/enum/group field types (resolution deferred).
28
39
  typeName?: string
40
+ // For an `extend` member: the message being extended, as written.
41
+ extendee?: string
42
+ // The `json_name = "..."` pseudo-option, lifted out of `options`.
43
+ jsonName?: string
44
+ // The `default = ...` pseudo-option, lifted out of `options`. Always a
45
+ // string, as in descriptor.proto; the literal is stored as written.
46
+ defaultValue?: string
29
47
  // proto3 explicit `optional` (synthesises a single-field oneof in protoc).
30
48
  proto3Optional?: boolean
31
49
  // The oneof this field belongs to, as an index into the message's
@@ -43,8 +61,9 @@ export interface EnumValueDescriptorProto {
43
61
  export interface EnumDescriptorProto {
44
62
  name: string
45
63
  value: EnumValueDescriptorProto[]
46
- reservedRange?: { start: number; end: number }[]
64
+ reservedRange?: DescriptorRange[]
47
65
  reservedName?: string[]
66
+ visibility?: SymbolVisibility
48
67
  options?: Record<string, OptionValue>
49
68
  }
50
69
 
@@ -60,9 +79,10 @@ export interface DescriptorProto {
60
79
  enumType: EnumDescriptorProto[]
61
80
  oneofDecl: OneofDescriptorProto[]
62
81
  extension: FieldDescriptorProto[]
63
- extensionRange?: { start: number; end: number }[]
64
- reservedRange?: { start: number; end: number }[]
82
+ extensionRange?: DescriptorRange[]
83
+ reservedRange?: DescriptorRange[]
65
84
  reservedName?: string[]
85
+ visibility?: SymbolVisibility
66
86
  options?: Record<string, OptionValue>
67
87
  }
68
88
 
@@ -88,17 +108,27 @@ export interface FileDescriptorProto {
88
108
  dependency: string[]
89
109
  publicDependency: number[]
90
110
  weakDependency: number[]
111
+ // `import option "..."` targets (edition 2024). Present only when used.
112
+ optionDependency?: string[]
91
113
  messageType: DescriptorProto[]
92
114
  enumType: EnumDescriptorProto[]
93
115
  service: ServiceDescriptorProto[]
94
116
  extension: FieldDescriptorProto[]
95
117
  options?: Record<string, OptionValue>
96
- // 'proto2' | 'proto3' for syntax files; absent for edition files.
118
+ // 'proto2' | 'proto3' for syntax files; 'editions' for edition files.
97
119
  syntax?: string
98
120
  // 'EDITION_2023' | 'EDITION_2024' for edition files; absent otherwise.
99
121
  edition?: string
100
122
  }
101
123
 
124
+ // protoc's sentinels for `to max` in a range.
125
+ // Message field numbers stop at 2^29-1, so an exclusive `end` is 2^29;
126
+ // a message_set_wire_format message may use the full 32-bit space.
127
+ export const MAX_FIELD_NUMBER_END = 536870912
128
+ export const MAX_MESSAGE_SET_END = 2147483647
129
+ // Enum numbers are plain int32 and enum reserved ranges are inclusive.
130
+ export const MAX_ENUM_NUMBER = 2147483647
131
+
102
132
  // Scalar protobuf types -> FieldDescriptorProto.type. A field whose type
103
133
  // is not in this table is a message/enum/group reference (resolution
104
134
  // deferred) and gets a `typeName` instead.
package/src/grammar.ts CHANGED
@@ -59,11 +59,19 @@ extendStmt = "extend" messageType "{" *( field / emptyStmt ) "}"
59
59
  extensions = "extensions" ranges [ fieldOptions ] ";"
60
60
  reserved = "reserved" ( ranges / fieldNames ) ";"
61
61
  ranges = range *( "," range )
62
- range = NR [ "to" ( NR / "max" ) ]
63
- fieldNames = strLit *( "," strLit )
64
-
65
- constant = boolLit / fullIdent / signedNumber / strLit / messageValue
62
+ ; Enum reserved ranges may be negative, so a range bound is signed.
63
+ range = [ "-" ] NR [ "to" ( [ "-" ] NR / "max" ) ]
64
+ ; Reserved names are string literals in proto2/proto3 and bare identifiers
65
+ ; from edition 2023 on; the union accepts either (and a mix).
66
+ fieldNames = reservedName *( "," reservedName )
67
+ reservedName = strLit / ident
68
+
69
+ constant = boolLit / fullIdent / signedNumber / signedIdent / strLit
70
+ / messageValue
66
71
  signedNumber = [ "-" / "+" ] NR
72
+ ; \`-inf\` / \`-nan\`: a negated identifier constant. Tried after signedNumber
73
+ ; so \`-33\` still lexes as a number.
74
+ signedIdent = "-" fullIdent
67
75
  boolLit = VL
68
76
  messageValue = "{" *messageValueEntry "}"
69
77
  messageValueEntry = ident [ ":" ] constant [ "," / ";" ]
@@ -79,6 +87,8 @@ emptyStmt = ";"
79
87
  ; optional/repeated), extend, and extensions live in common (the permissive
80
88
  ; union); the descriptor walk enforces what each version actually allows.
81
89
  messageElement =/ groupField
90
+ ; A group is also a legal oneof member (\`oneof foo { group D = 4 {…} }\`).
91
+ oneofElement =/ groupField
82
92
  groupField = [ label ] "group" ident "=" fieldNumber messageBody
83
93
 
84
94
  ; ===== proto3.abnf =====
@@ -99,4 +109,9 @@ importStmt =/ "import" "option" strLit ";"
99
109
  topLevelDef =/ symbolVisibility message / symbolVisibility enumDef
100
110
  messageElement =/ symbolVisibility message / symbolVisibility enumDef
101
111
  symbolVisibility = "export" / "local"
112
+ ; \`export\` and \`local\` are contextual: they are only visibility modifiers
113
+ ; ahead of \`message\`/\`enum\`, and stay usable as ordinary name components
114
+ ; (\`local.pkg.SomeMessage\`). The union grammar lexes them as keywords, so
115
+ ; give fullIdent an alternative that may open with one.
116
+ fullIdent =/ symbolVisibility *( "." ident )
102
117
  `