Skip to content

Commit 8c039c5

Browse files
authored
feat: add strict option to CLI (#119)
Instead of throwing when encountering proto2 syntax, log a warning. Add a `--strict` option to turn this warning into an error. This is required to support [custom options](https://protobuf.dev/programming-guides/proto3/#customoptions) which can necessitate compiling built in proto2 definitions (e.g. `extend google.protobuf.FileOptions`).
1 parent e86d817 commit 8c039c5

18 files changed

+655
-246
lines changed

packages/protons/bin/protons.ts

+5
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ async function main (): Promise<void> {
1010
1111
Options
1212
--output, -o Path to a directory to write transpiled typescript files into
13+
--strict, -s Causes parsing warnings to become errors
1314
1415
Examples
1516
$ protons ./path/to/file.proto ./path/to/other/file.proto
@@ -20,6 +21,10 @@ async function main (): Promise<void> {
2021
output: {
2122
type: 'string',
2223
shortFlag: 'o'
24+
},
25+
strict: {
26+
type: 'boolean',
27+
shortFlag: 's'
2328
}
2429
}
2530
})

packages/protons/src/index.ts

+78-16
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,16 @@ function pathWithExtension (input: string, extension: string, outputDir?: string
1919
return path.join(output, path.basename(input).split('.').slice(0, -1).join('.') + extension)
2020
}
2121

22+
export class CodeError extends Error {
23+
public code: string
24+
25+
constructor (message: string, code: string, options?: ErrorOptions) {
26+
super(message, options)
27+
28+
this.code = code
29+
}
30+
}
31+
2232
const types: Record<string, string> = {
2333
bool: 'boolean',
2434
bytes: 'Uint8Array',
@@ -287,6 +297,13 @@ interface FieldDef {
287297
map: boolean
288298
valueType: string
289299
keyType: string
300+
301+
/**
302+
* Support proto2 required field. This field means a value should always be
303+
* in the serialized buffer, any message without it should be considered
304+
* invalid. It was removed for proto3.
305+
*/
306+
proto2Required: boolean
290307
}
291308

292309
function defineFields (fields: Record<string, FieldDef>, messageDef: MessageDef, moduleDef: ModuleDef): string[] {
@@ -299,10 +316,22 @@ function defineFields (fields: Record<string, FieldDef>, messageDef: MessageDef,
299316
})
300317
}
301318

302-
function compileMessage (messageDef: MessageDef, moduleDef: ModuleDef): string {
319+
function compileMessage (messageDef: MessageDef, moduleDef: ModuleDef, flags?: Flags): string {
303320
if (isEnumDef(messageDef)) {
304321
moduleDef.imports.add('enumeration')
305322

323+
// check that the enum def values start from 0
324+
if (Object.values(messageDef.values)[0] !== 0) {
325+
const message = `enum ${messageDef.name} does not contain a value that maps to zero as it's first element, this is required in proto3 - see https://protobuf.dev/programming-guides/proto3/#enum`
326+
327+
if (flags?.strict === true) {
328+
throw new CodeError(message, 'ERR_PARSE_ERROR')
329+
} else {
330+
// eslint-disable-next-line no-console
331+
console.info(`[WARN] ${message}`)
332+
}
333+
}
334+
306335
return `
307336
export enum ${messageDef.name} {
308337
${
@@ -332,7 +361,7 @@ export namespace ${messageDef.name} {
332361
if (messageDef.nested != null) {
333362
nested = '\n'
334363
nested += Object.values(messageDef.nested)
335-
.map(def => compileMessage(def, moduleDef).trim())
364+
.map(def => compileMessage(def, moduleDef, flags).trim())
336365
.join('\n\n')
337366
.split('\n')
338367
.map(line => line.trim() === '' ? '' : ` ${line}`)
@@ -391,13 +420,25 @@ export interface ${messageDef.name} {
391420

392421
if (fieldDef.map) {
393422
valueTest = `obj.${name} != null && obj.${name}.size !== 0`
394-
} else if (!fieldDef.optional && !fieldDef.repeated) {
423+
} else if (!fieldDef.optional && !fieldDef.repeated && !fieldDef.proto2Required) {
395424
// proto3 singular fields should only be written out if they are not the default value
396425
if (defaultValueTestGenerators[type] != null) {
397426
valueTest = `${defaultValueTestGenerators[type](`obj.${name}`)}`
398427
} else if (type === 'enum') {
399428
// handle enums
400-
valueTest = `obj.${name} != null && __${fieldDef.type}Values[obj.${name}] !== 0`
429+
const def = findDef(fieldDef.type, messageDef, moduleDef)
430+
431+
if (!isEnumDef(def)) {
432+
throw new Error(`${fieldDef.type} was not enum def`)
433+
}
434+
435+
valueTest = `obj.${name} != null`
436+
437+
// singular enums default to 0, but enums can be defined without a 0
438+
// value which is against the proto3 spec but is tolerated
439+
if (Object.values(def.values)[0] === 0) {
440+
valueTest += ` && __${fieldDef.type}Values[obj.${name}] !== 0`
441+
}
401442
}
402443
}
403444

@@ -496,14 +537,16 @@ export interface ${messageDef.name} {
496537
break
497538
}`
498539
} else if (fieldDef.repeated) {
499-
return `case ${fieldDef.id}:
540+
return `case ${fieldDef.id}: {
500541
obj.${fieldName}.push(${parseValue})
501-
break`
542+
break
543+
}`
502544
}
503545

504-
return `case ${fieldDef.id}:
546+
return `case ${fieldDef.id}: {
505547
obj.${fieldName} = ${parseValue}
506-
break`
548+
break
549+
}`
507550
}
508551

509552
return createReadField(fieldName, fieldDef)
@@ -532,9 +575,10 @@ ${encodeFields === '' ? '' : `${encodeFields}\n`}
532575
const tag = reader.uint32()
533576
534577
switch (tag >>> 3) {${decodeFields === '' ? '' : `\n ${decodeFields}`}
535-
default:
578+
default: {
536579
reader.skipType(tag & 7)
537580
break
581+
}
538582
}
539583
}
540584
@@ -570,7 +614,7 @@ interface ModuleDef {
570614
globals: Record<string, ClassDef>
571615
}
572616

573-
function defineModule (def: ClassDef): ModuleDef {
617+
function defineModule (def: ClassDef, flags: Flags): ModuleDef {
574618
const moduleDef: ModuleDef = {
575619
imports: new Set(),
576620
importedTypes: new Set(),
@@ -582,10 +626,10 @@ function defineModule (def: ClassDef): ModuleDef {
582626
const defs = def.nested
583627

584628
if (defs == null) {
585-
throw new Error('No top-level messages found in protobuf')
629+
throw new CodeError('No top-level messages found in protobuf', 'ERR_NO_MESSAGES_FOUND')
586630
}
587631

588-
function defineMessage (defs: Record<string, ClassDef>, parent?: ClassDef): void {
632+
function defineMessage (defs: Record<string, ClassDef>, parent?: ClassDef, flags?: Flags): void {
589633
for (const className of Object.keys(defs)) {
590634
const classDef = defs[className]
591635

@@ -603,9 +647,19 @@ function defineModule (def: ClassDef): ModuleDef {
603647
fieldDef.repeated = fieldDef.rule === 'repeated'
604648
fieldDef.optional = !fieldDef.repeated && fieldDef.options?.proto3_optional === true
605649
fieldDef.map = fieldDef.keyType != null
650+
fieldDef.proto2Required = false
606651

607652
if (fieldDef.rule === 'required') {
608-
throw new Error('"required" fields are not allowed in proto3 - please convert your proto2 definitions to proto3')
653+
const message = `field "${name}" is required, this is not allowed in proto3. Please convert your proto2 definitions to proto3 - see https://github.com/ipfs/protons/wiki/Required-fields-and-protobuf-3`
654+
655+
if (flags?.strict === true) {
656+
throw new CodeError(message, 'ERR_PARSE_ERROR')
657+
} else {
658+
fieldDef.proto2Required = true
659+
660+
// eslint-disable-next-line no-console
661+
console.info(`[WARN] ${message}`)
662+
}
609663
}
610664
}
611665
}
@@ -644,22 +698,30 @@ function defineModule (def: ClassDef): ModuleDef {
644698
}
645699
}
646700

647-
defineMessage(defs)
701+
defineMessage(defs, undefined, flags)
648702

649703
// set enum/message fields now all messages have been defined
650704
updateTypes(defs)
651705

652706
for (const className of Object.keys(defs)) {
653707
const classDef = defs[className]
654708

655-
moduleDef.compiled.push(compileMessage(classDef, moduleDef))
709+
moduleDef.compiled.push(compileMessage(classDef, moduleDef, flags))
656710
}
657711

658712
return moduleDef
659713
}
660714

661715
interface Flags {
716+
/**
717+
* Specifies an output directory
718+
*/
662719
output?: string
720+
721+
/**
722+
* If true, warnings will be thrown as errors
723+
*/
724+
strict?: boolean
663725
}
664726

665727
export async function generate (source: string, flags: Flags): Promise<void> {
@@ -701,7 +763,7 @@ export async function generate (source: string, flags: Flags): Promise<void> {
701763
}
702764
}
703765

704-
const moduleDef = defineModule(def)
766+
const moduleDef = defineModule(def, flags)
705767

706768
const ignores = [
707769
'/* eslint-disable import/export */',
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
syntax = "proto3";
2+
3+
enum AnEnum {
4+
// enum values should start from 0
5+
value1 = 1;
6+
}

packages/protons/test/fixtures/basic.ts

+8-4
Original file line numberDiff line numberDiff line change
@@ -47,15 +47,18 @@ export namespace Basic {
4747
const tag = reader.uint32()
4848

4949
switch (tag >>> 3) {
50-
case 1:
50+
case 1: {
5151
obj.foo = reader.string()
5252
break
53-
case 2:
53+
}
54+
case 2: {
5455
obj.num = reader.int32()
5556
break
56-
default:
57+
}
58+
default: {
5759
reader.skipType(tag & 7)
5860
break
61+
}
5962
}
6063
}
6164

@@ -99,9 +102,10 @@ export namespace Empty {
99102
const tag = reader.uint32()
100103

101104
switch (tag >>> 3) {
102-
default:
105+
default: {
103106
reader.skipType(tag & 7)
104107
break
108+
}
105109
}
106110
}
107111

0 commit comments

Comments
 (0)