From 360bdb90419a01c5e7281227a40b0a7912aa7a51 Mon Sep 17 00:00:00 2001 From: Timo Stamm Date: Fri, 8 Dec 2023 12:56:31 +0100 Subject: [PATCH] Add GeneratedFile.jsDoc() and other features to @bufbuild/protoplugin (#649) --- packages/protoc-gen-es/src/declaration.ts | 16 +- packages/protoc-gen-es/src/javascript.ts | 14 +- packages/protoc-gen-es/src/typescript.ts | 16 +- .../src/byo-transpile.test.ts | 7 +- .../src/deprecated-jsdoc.test.ts | 138 ++++++++++++++++++ .../src/file-export-decl.test.ts | 7 +- .../protoplugin-test/src/file-jsdoc.test.ts | 137 +++++++++++++++++ .../src/file-preamble.test.ts | 7 +- .../protoplugin-test/src/file-string.test.ts | 81 ++++++++++ .../src/generated-file.test.ts | 9 +- .../src/import_extension.test.ts | 8 +- .../src/js_import_style.test.ts | 7 +- .../src/keep_empty_files.test.ts | 10 +- .../src/rewrite_imports.test.ts | 8 +- .../protoplugin-test/src/transpile.test.ts | 7 +- packages/protoplugin/src/create-es-plugin.ts | 27 +++- .../protoplugin/src/ecmascript/gencommon.ts | 90 ------------ .../src/ecmascript/generated-file.ts | 43 +++++- packages/protoplugin/src/ecmascript/index.ts | 39 ++++- packages/protoplugin/src/ecmascript/jsdoc.ts | 102 +++++++++++++ packages/protoplugin/src/ecmascript/schema.ts | 36 +---- packages/protoplugin/src/index.ts | 7 +- 22 files changed, 641 insertions(+), 175 deletions(-) create mode 100644 packages/protoplugin-test/src/deprecated-jsdoc.test.ts create mode 100644 packages/protoplugin-test/src/file-jsdoc.test.ts create mode 100644 packages/protoplugin-test/src/file-string.test.ts create mode 100644 packages/protoplugin/src/ecmascript/jsdoc.ts diff --git a/packages/protoc-gen-es/src/declaration.ts b/packages/protoc-gen-es/src/declaration.ts index c1b67f2c9..7d055106c 100644 --- a/packages/protoc-gen-es/src/declaration.ts +++ b/packages/protoc-gen-es/src/declaration.ts @@ -25,9 +25,7 @@ import type { } from "@bufbuild/protoplugin/ecmascript"; import { getFieldTyping, - literalString, localName, - makeJsDoc, reifyWkt, } from "@bufbuild/protoplugin/ecmascript"; import { getNonEditionRuntime } from "./editions.js"; @@ -48,13 +46,13 @@ export function generateDts(schema: Schema) { // prettier-ignore function generateEnum(schema: Schema, f: GeneratedFile, enumeration: DescEnum) { - f.print(makeJsDoc(enumeration)); + f.print(f.jsDoc(enumeration)); f.print("export declare enum ", enumeration, " {"); for (const value of enumeration.values) { if (enumeration.values.indexOf(value) > 0) { f.print(); } - f.print(makeJsDoc(value, " ")); + f.print(f.jsDoc(value, " ")); f.print(" ", localName(value), " = ", value.number, ","); } f.print("}"); @@ -73,7 +71,7 @@ function generateMessage(schema: Schema, f: GeneratedFile, message: DescMessage) JsonReadOptions, JsonValue } = schema.runtime; - f.print(makeJsDoc(message)); + f.print(f.jsDoc(message)); f.print("export declare class ", message, " extends ", Message, "<", message, "> {"); for (const member of message.members) { switch (member.kind) { @@ -90,7 +88,7 @@ function generateMessage(schema: Schema, f: GeneratedFile, message: DescMessage) f.print(); generateWktMethods(schema, f, message); f.print(" static readonly runtime: typeof ", protoN, ";"); - f.print(' static readonly typeName = ', literalString(message.typeName), ';'); + f.print(' static readonly typeName = ', f.string(message.typeName), ';'); f.print(" static readonly fields: ", FieldList, ";"); // In case we start supporting options, we have to surface them here //f.print(" static readonly options: { readonly [extensionName: string]: ", rt.JsonValue, " } = {};") @@ -116,13 +114,13 @@ function generateMessage(schema: Schema, f: GeneratedFile, message: DescMessage) // prettier-ignore function generateOneof(schema: Schema, f: GeneratedFile, oneof: DescOneof) { - f.print(makeJsDoc(oneof, " ")); + f.print(f.jsDoc(oneof, " ")); f.print(" ", localName(oneof), ": {"); for (const field of oneof.fields) { if (oneof.fields.indexOf(field) > 0) { f.print(` } | {`); } - f.print(makeJsDoc(field, " ")); + f.print(f.jsDoc(field, " ")); const { typing } = getFieldTyping(field, f); f.print(` value: `, typing, `;`); f.print(` case: "`, localName(field), `";`); @@ -131,7 +129,7 @@ function generateOneof(schema: Schema, f: GeneratedFile, oneof: DescOneof) { } function generateField(schema: Schema, f: GeneratedFile, field: DescField) { - f.print(makeJsDoc(field, " ")); + f.print(f.jsDoc(field, " ")); const e: Printable = []; e.push(" ", localName(field)); const { typing, optional } = getFieldTyping(field, f); diff --git a/packages/protoc-gen-es/src/javascript.ts b/packages/protoc-gen-es/src/javascript.ts index 660adf744..11e1b6daa 100644 --- a/packages/protoc-gen-es/src/javascript.ts +++ b/packages/protoc-gen-es/src/javascript.ts @@ -27,9 +27,7 @@ import type { } from "@bufbuild/protoplugin/ecmascript"; import { getFieldExplicitDefaultValue, - literalString, localName, - makeJsDoc, reifyWkt, } from "@bufbuild/protoplugin/ecmascript"; import { getNonEditionRuntime } from "./editions.js"; @@ -51,18 +49,18 @@ export function generateJs(schema: Schema) { // prettier-ignore function generateEnum(schema: Schema, f: GeneratedFile, enumeration: DescEnum) { const protoN = getNonEditionRuntime(schema, enumeration.file); - f.print(makeJsDoc(enumeration)); + f.print(f.jsDoc(enumeration)); f.print(f.exportDecl("const", enumeration), " = ", protoN, ".makeEnum(") f.print(` "`, enumeration.typeName, `",`) f.print(` [`) if (enumeration.sharedPrefix === undefined) { for (const value of enumeration.values) { - f.print(" {no: ", value.number, ", name: ", literalString(value.name), "},") + f.print(" {no: ", value.number, ", name: ", f.string(value.name), "},") } } else { for (const value of enumeration.values) { const localName = value.name.substring(enumeration.sharedPrefix.length); - f.print(" {no: ", value.number, ", name: ", literalString(value.name), ", localName: ", literalString(localName), "},") + f.print(" {no: ", value.number, ", name: ", f.string(value.name), ", localName: ", f.string(localName), "},") } } f.print(` ],`) @@ -73,9 +71,9 @@ function generateEnum(schema: Schema, f: GeneratedFile, enumeration: DescEnum) { // prettier-ignore function generateMessage(schema: Schema, f: GeneratedFile, message: DescMessage) { const protoN = getNonEditionRuntime(schema, message.file); - f.print(makeJsDoc(message)); + f.print(f.jsDoc(message)); f.print(f.exportDecl("const", message), " = ", protoN, ".makeMessageType(") - f.print(` `, literalString(message.typeName), `,`) + f.print(` `, f.string(message.typeName), `,`) if (message.fields.length == 0) { f.print(" [],") } else { @@ -90,7 +88,7 @@ function generateMessage(schema: Schema, f: GeneratedFile, message: DescMessage) .makeMessageType(message.typeName, []).name; if (needsLocalName) { // local name is not inferrable from the type name, we need to provide it - f.print(` {localName: `, literalString(localName(message)), `},`) + f.print(` {localName: `, f.string(localName(message)), `},`) } f.print(");") f.print() diff --git a/packages/protoc-gen-es/src/typescript.ts b/packages/protoc-gen-es/src/typescript.ts index dd56b9897..045df0a5c 100644 --- a/packages/protoc-gen-es/src/typescript.ts +++ b/packages/protoc-gen-es/src/typescript.ts @@ -27,9 +27,7 @@ import type { import { getFieldIntrinsicDefaultValue, getFieldTyping, - literalString, localName, - makeJsDoc, reifyWkt, } from "@bufbuild/protoplugin/ecmascript"; import { generateFieldInfo } from "./javascript.js"; @@ -52,13 +50,13 @@ export function generateTs(schema: Schema) { // prettier-ignore function generateEnum(schema: Schema, f: GeneratedFile, enumeration: DescEnum) { const protoN = getNonEditionRuntime(schema, enumeration.file); - f.print(makeJsDoc(enumeration)); + f.print(f.jsDoc(enumeration)); f.print(f.exportDecl("enum", enumeration), " {"); for (const value of enumeration.values) { if (enumeration.values.indexOf(value) > 0) { f.print(); } - f.print(makeJsDoc(value, " ")); + f.print(f.jsDoc(value, " ")); f.print(" ", localName(value), " = ", value.number, ","); } f.print("}"); @@ -83,7 +81,7 @@ function generateMessage(schema: Schema, f: GeneratedFile, message: DescMessage) JsonReadOptions, JsonValue } = schema.runtime; - f.print(makeJsDoc(message)); + f.print(f.jsDoc(message)); f.print(f.exportDecl("class", message), " extends ", Message, "<", message, "> {"); for (const member of message.members) { switch (member.kind) { @@ -103,7 +101,7 @@ function generateMessage(schema: Schema, f: GeneratedFile, message: DescMessage) f.print(); generateWktMethods(schema, f, message); f.print(" static readonly runtime: typeof ", protoN, " = ", protoN, ";"); - f.print(' static readonly typeName = ', literalString(message.typeName), ';'); + f.print(' static readonly typeName = ', f.string(message.typeName), ';'); f.print(" static readonly fields: ", FieldList, " = ", protoN, ".util.newFieldList(() => ["); for (const field of message.fields) { generateFieldInfo(schema, f, field); @@ -141,13 +139,13 @@ function generateMessage(schema: Schema, f: GeneratedFile, message: DescMessage) // prettier-ignore function generateOneof(schema: Schema, f: GeneratedFile, oneof: DescOneof) { - f.print(makeJsDoc(oneof, " ")); + f.print(f.jsDoc(oneof, " ")); f.print(" ", localName(oneof), ": {"); for (const field of oneof.fields) { if (oneof.fields.indexOf(field) > 0) { f.print(` } | {`); } - f.print(makeJsDoc(field, " ")); + f.print(f.jsDoc(field, " ")); const { typing } = getFieldTyping(field, f); f.print(` value: `, typing, `;`); f.print(` case: "`, localName(field), `";`); @@ -156,7 +154,7 @@ function generateOneof(schema: Schema, f: GeneratedFile, oneof: DescOneof) { } function generateField(schema: Schema, f: GeneratedFile, field: DescField) { - f.print(makeJsDoc(field, " ")); + f.print(f.jsDoc(field, " ")); const e: Printable = []; e.push(" ", localName(field)); const { defaultValue, typingInferrable } = diff --git a/packages/protoplugin-test/src/byo-transpile.test.ts b/packages/protoplugin-test/src/byo-transpile.test.ts index 5518f3b6d..060d3e8a7 100644 --- a/packages/protoplugin-test/src/byo-transpile.test.ts +++ b/packages/protoplugin-test/src/byo-transpile.test.ts @@ -89,7 +89,10 @@ describe("bring your own transpile", () => { }); const res = plugin.run(req); expect(res.file.length).toBeGreaterThanOrEqual(1); - const content = res.file[0]?.content ?? ""; - return content.trim().split("\n"); + let content = res.file[0]?.content ?? ""; + if (content.endsWith("\n")) { + content = content.slice(0, -1); // trim final newline so we don't return an extra line + } + return content.split("\n"); } }); diff --git a/packages/protoplugin-test/src/deprecated-jsdoc.test.ts b/packages/protoplugin-test/src/deprecated-jsdoc.test.ts new file mode 100644 index 000000000..bd5f1264c --- /dev/null +++ b/packages/protoplugin-test/src/deprecated-jsdoc.test.ts @@ -0,0 +1,138 @@ +// Copyright 2021-2023 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { describe, expect, test } from "@jest/globals"; +import { UpstreamProtobuf } from "upstream-protobuf"; +import { CodeGeneratorRequest } from "@bufbuild/protobuf"; +import type { Schema } from "@bufbuild/protoplugin/ecmascript"; +import { createEcmaScriptPlugin } from "@bufbuild/protoplugin"; +import type { GeneratedFile } from "@bufbuild/protoplugin/ecmascript"; +import { createJsDocBlock, makeJsDoc } from "@bufbuild/protoplugin/ecmascript"; + +describe("deprecated makeJsDoc() and createJsDocBlock()", () => { + test("creates JSDoc comment block", async () => { + const lines = await testGenerate(`syntax="proto3";`, (f) => { + f.print(createJsDocBlock(`hello world`)); + }); + expect(lines).toStrictEqual(["/**", " * hello world", " */"]); + }); + + test("creates JSDoc comment block for message descriptor", async () => { + const lines = await testGenerate( + ` + syntax="proto3"; + message SomeMessage {}; + `, + (f, schema) => { + f.print(makeJsDoc(schema.files[0].messages[0])); + }, + ); + expect(lines).toStrictEqual([ + "/**", + " * @generated from message SomeMessage", + " */", + ]); + }); + + test("creates JSDoc comment block for message descriptor with comments", async () => { + const lines = await testGenerate( + ` + syntax="proto3"; + + // discarded detached comment + + // comment on message + message SomeMessage {}; + `, + (f, schema) => { + f.print(makeJsDoc(schema.files[0].messages[0])); + }, + ); + expect(lines).toStrictEqual([ + "/**", + " * comment on message", + " *", + " * @generated from message SomeMessage", + " */", + ]); + }); + + test("indents", async () => { + const lines = await testGenerate(`syntax="proto3";`, (f) => { + f.print(createJsDocBlock(`multi-line\ncomment`, " ")); + }); + expect(lines).toStrictEqual([ + " /**", + " * multi-line", + " * comment", + " */", + ]); + }); + + test("escapes */", async () => { + const lines = await testGenerate(`syntax="proto3";`, (f) => { + f.print(createJsDocBlock(`*/`)); + }); + expect(lines).toStrictEqual(["/**", " * *\\/", " */"]); + }); + + test("whitespace is unmodified", async () => { + const lines = await testGenerate(`syntax="proto3";`, (f) => { + f.print(createJsDocBlock(`\na\n b\n c\t`)); + }); + expect(lines).toStrictEqual([ + "/**", + " *", + " * a", + " * b", + " * c\t", + " */", + ]); + }); + + async function testGenerate( + protoContent: string, + gen: (f: GeneratedFile, schema: Schema) => void, + ) { + const plugin = createEcmaScriptPlugin({ + name: "test", + version: "v1", + generateTs: generateAny, + generateJs: generateAny, + generateDts: generateAny, + }); + + function generateAny(schema: Schema) { + gen(schema.generateFile("test.ts"), schema); + } + + const upstream = new UpstreamProtobuf(); + const protoFiles = { + "x.proto": protoContent, + }; + const req = CodeGeneratorRequest.fromBinary( + await upstream.createCodeGeneratorRequest(protoFiles, { + parameter: "target=ts", + }), + ); + expect(req.protoFile.length).toBe(1); + const res = plugin.run(req); + expect(res.file.length).toBeGreaterThanOrEqual(1); + let content = res.file[0]?.content ?? ""; + if (content.endsWith("\n")) { + content = content.slice(0, -1); // trim final newline so we don't return an extra line + } + return content.split("\n"); + } +}); diff --git a/packages/protoplugin-test/src/file-export-decl.test.ts b/packages/protoplugin-test/src/file-export-decl.test.ts index f0b03cd12..4acb2835f 100644 --- a/packages/protoplugin-test/src/file-export-decl.test.ts +++ b/packages/protoplugin-test/src/file-export-decl.test.ts @@ -95,7 +95,10 @@ describe("file exportDecl", () => { expect(req.protoFile[0]?.enumType.length).toBe(1); const res = plugin.run(req); expect(res.file.length).toBeGreaterThanOrEqual(1); - const content = res.file[0]?.content ?? ""; - return content.trim().split("\n"); + let content = res.file[0]?.content ?? ""; + if (content.endsWith("\n")) { + content = content.slice(0, -1); // trim final newline so we don't return an extra line + } + return content.split("\n"); } }); diff --git a/packages/protoplugin-test/src/file-jsdoc.test.ts b/packages/protoplugin-test/src/file-jsdoc.test.ts new file mode 100644 index 000000000..d1193692e --- /dev/null +++ b/packages/protoplugin-test/src/file-jsdoc.test.ts @@ -0,0 +1,137 @@ +// Copyright 2021-2023 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { describe, expect, test } from "@jest/globals"; +import { UpstreamProtobuf } from "upstream-protobuf"; +import { CodeGeneratorRequest } from "@bufbuild/protobuf"; +import type { Schema } from "@bufbuild/protoplugin"; +import { createEcmaScriptPlugin } from "@bufbuild/protoplugin"; +import type { GeneratedFile } from "@bufbuild/protoplugin/ecmascript"; + +describe("file jsDoc", () => { + test("creates JSDoc comment block", async () => { + const lines = await testGenerate(`syntax="proto3";`, (f) => { + f.print(f.jsDoc(`hello world`)); + }); + expect(lines).toStrictEqual(["/**", " * hello world", " */"]); + }); + + test("creates JSDoc comment block for message descriptor", async () => { + const lines = await testGenerate( + ` + syntax="proto3"; + message SomeMessage {}; + `, + (f, schema) => { + f.print(f.jsDoc(schema.files[0].messages[0])); + }, + ); + expect(lines).toStrictEqual([ + "/**", + " * @generated from message SomeMessage", + " */", + ]); + }); + + test("creates JSDoc comment block for message descriptor with comments", async () => { + const lines = await testGenerate( + ` + syntax="proto3"; + + // discarded detached comment + + // comment on message + message SomeMessage {}; + `, + (f, schema) => { + f.print(f.jsDoc(schema.files[0].messages[0])); + }, + ); + expect(lines).toStrictEqual([ + "/**", + " * comment on message", + " *", + " * @generated from message SomeMessage", + " */", + ]); + }); + + test("indents", async () => { + const lines = await testGenerate(`syntax="proto3";`, (f) => { + f.print(f.jsDoc(`multi-line\ncomment`, " ")); + }); + expect(lines).toStrictEqual([ + " /**", + " * multi-line", + " * comment", + " */", + ]); + }); + + test("escapes */", async () => { + const lines = await testGenerate(`syntax="proto3";`, (f) => { + f.print(f.jsDoc(`*/`)); + }); + expect(lines).toStrictEqual(["/**", " * *\\/", " */"]); + }); + + test("whitespace is unmodified", async () => { + const lines = await testGenerate(`syntax="proto3";`, (f) => { + f.print(f.jsDoc(`\na\n b\n c\t`)); + }); + expect(lines).toStrictEqual([ + "/**", + " *", + " * a", + " * b", + " * c\t", + " */", + ]); + }); + + async function testGenerate( + protoContent: string, + gen: (f: GeneratedFile, schema: Schema) => void, + ) { + const plugin = createEcmaScriptPlugin({ + name: "test", + version: "v1", + generateTs: generateAny, + generateJs: generateAny, + generateDts: generateAny, + }); + + function generateAny(schema: Schema) { + gen(schema.generateFile("test.ts"), schema); + } + + const upstream = new UpstreamProtobuf(); + const protoFiles = { + "x.proto": protoContent, + }; + const req = CodeGeneratorRequest.fromBinary( + await upstream.createCodeGeneratorRequest(protoFiles, { + parameter: "target=ts", + }), + ); + expect(req.protoFile.length).toBe(1); + const res = plugin.run(req); + expect(res.file.length).toBeGreaterThanOrEqual(1); + let content = res.file[0]?.content ?? ""; + if (content.endsWith("\n")) { + content = content.slice(0, -1); // trim final newline so we don't return an extra line + } + return content.split("\n"); + } +}); diff --git a/packages/protoplugin-test/src/file-preamble.test.ts b/packages/protoplugin-test/src/file-preamble.test.ts index 491ed3563..c69fd6329 100644 --- a/packages/protoplugin-test/src/file-preamble.test.ts +++ b/packages/protoplugin-test/src/file-preamble.test.ts @@ -205,7 +205,10 @@ describe("file preamble", () => { expect(req.protoFile.length).toBe(1); const res = plugin.run(req); expect(res.file.length).toBeGreaterThanOrEqual(1); - const content = res.file[0]?.content ?? ""; - return content.trim().split("\n"); + let content = res.file[0]?.content ?? ""; + if (content.endsWith("\n")) { + content = content.slice(0, -1); // trim final newline so we don't return an extra line + } + return content.split("\n"); } }); diff --git a/packages/protoplugin-test/src/file-string.test.ts b/packages/protoplugin-test/src/file-string.test.ts new file mode 100644 index 000000000..4c4dc452b --- /dev/null +++ b/packages/protoplugin-test/src/file-string.test.ts @@ -0,0 +1,81 @@ +// Copyright 2021-2023 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { describe, expect, test } from "@jest/globals"; +import { CodeGeneratorRequest } from "@bufbuild/protobuf"; +import type { Schema } from "@bufbuild/protoplugin"; +import { createEcmaScriptPlugin } from "@bufbuild/protoplugin"; +import type { GeneratedFile } from "@bufbuild/protoplugin/ecmascript"; + +describe("file string", () => { + test("surrounds string in quotes", () => { + const lines = testGenerate((f) => { + f.print("const s = ", f.string("abc"), ";"); + }); + expect(lines).toStrictEqual([`const s = "abc";`]); + }); + + test("surrounds string in quotes", () => { + const lines = testGenerate((f) => { + f.print(f.string("abc")); + }); + expect(lines).toStrictEqual([`"abc"`]); + }); + + test("escapes quote", () => { + const lines = testGenerate((f) => { + f.print(f.string(`ab"c`)); + }); + expect(lines).toStrictEqual([`"ab\\"c"`]); + }); + + test("escapes backslash", () => { + const lines = testGenerate((f) => { + f.print(f.string("ab\\c")); + }); + expect(lines).toStrictEqual([`"ab\\\\c"`]); + }); + + test("escapes line breaks", () => { + const lines = testGenerate((f) => { + f.print(f.string("ab\r\nc")); + }); + expect(lines).toStrictEqual([`"ab\\r\\nc"`]); + }); + + function testGenerate(gen: (f: GeneratedFile) => void) { + const plugin = createEcmaScriptPlugin({ + name: "test", + version: "v1", + generateTs: generateAny, + generateJs: generateAny, + generateDts: generateAny, + }); + + function generateAny(schema: Schema) { + gen(schema.generateFile("test.ts")); + } + + const req = new CodeGeneratorRequest({ + parameter: "target=js", + }); + const res = plugin.run(req); + expect(res.file.length).toBeGreaterThanOrEqual(1); + let content = res.file[0]?.content ?? ""; + if (content.endsWith("\n")) { + content = content.slice(0, -1); // trim final newline so we don't return an extra line + } + return content.split("\n"); + } +}); diff --git a/packages/protoplugin-test/src/generated-file.test.ts b/packages/protoplugin-test/src/generated-file.test.ts index 5b93a7490..3c5ba9b6e 100644 --- a/packages/protoplugin-test/src/generated-file.test.ts +++ b/packages/protoplugin-test/src/generated-file.test.ts @@ -40,9 +40,11 @@ function generate( if (res.file.length !== 1) { throw new Error(`no file generated`); } - - const content = res.file[0].content ?? ""; - return content.trim().split("\n"); + let content = res.file[0]?.content ?? ""; + if (content.endsWith("\n")) { + content = content.slice(0, -1); // trim final newline so we don't return an extra line + } + return content.split("\n"); } describe("generated file", () => { @@ -149,6 +151,7 @@ export function foo(): ${Foo} { "", " return new Foo();", "};", + "", ]); }); test("empty literal", () => { diff --git a/packages/protoplugin-test/src/import_extension.test.ts b/packages/protoplugin-test/src/import_extension.test.ts index 3d32a9937..488e395eb 100644 --- a/packages/protoplugin-test/src/import_extension.test.ts +++ b/packages/protoplugin-test/src/import_extension.test.ts @@ -42,9 +42,11 @@ function generate( if (res.file.length !== 1) { throw new Error(`no file generated`); } - - const content = res.file[0].content ?? ""; - return content.trim().split("\n"); + let content = res.file[0]?.content ?? ""; + if (content.endsWith("\n")) { + content = content.slice(0, -1); // trim final newline so we don't return an extra line + } + return content.split("\n"); } describe("import_extension", function () { diff --git a/packages/protoplugin-test/src/js_import_style.test.ts b/packages/protoplugin-test/src/js_import_style.test.ts index 046a5c422..9c5e33d02 100644 --- a/packages/protoplugin-test/src/js_import_style.test.ts +++ b/packages/protoplugin-test/src/js_import_style.test.ts @@ -119,7 +119,10 @@ describe("js_import_style", () => { }); const res = plugin.run(req); expect(res.file.length).toBeGreaterThanOrEqual(1); - const content = res.file[0]?.content ?? ""; - return content.trim().split("\n"); + let content = res.file[0]?.content ?? ""; + if (content.endsWith("\n")) { + content = content.slice(0, -1); // trim final newline so we don't return an extra line + } + return content.split("\n"); } }); diff --git a/packages/protoplugin-test/src/keep_empty_files.test.ts b/packages/protoplugin-test/src/keep_empty_files.test.ts index ba44608c8..93e282028 100644 --- a/packages/protoplugin-test/src/keep_empty_files.test.ts +++ b/packages/protoplugin-test/src/keep_empty_files.test.ts @@ -113,9 +113,17 @@ describe("keep_empty_files", () => { ); expect(req.fileToGenerate.length).toBe(1); const res = plugin.run(req); + let lines: string[] | undefined; + if (res.file.length > 0) { + let content = res.file[0]?.content ?? ""; + if (content.endsWith("\n")) { + content = content.slice(0, -1); // trim final newline so we don't return an extra line + } + lines = content.split("\n"); + } return { fileCount: res.file.length, - lines: res.file[0]?.content?.trim().split("\n"), + lines, }; } }); diff --git a/packages/protoplugin-test/src/rewrite_imports.test.ts b/packages/protoplugin-test/src/rewrite_imports.test.ts index 2c0c0c252..6a10a5b10 100644 --- a/packages/protoplugin-test/src/rewrite_imports.test.ts +++ b/packages/protoplugin-test/src/rewrite_imports.test.ts @@ -44,9 +44,11 @@ function generate( if (res.file.length !== 1) { throw new Error(`no file generated`); } - - const content = res.file[0].content ?? ""; - return content.trim().split("\n"); + let content = res.file[0]?.content ?? ""; + if (content.endsWith("\n")) { + content = content.slice(0, -1); // trim final newline so we don't return an extra line + } + return content.split("\n"); } describe("rewrite_imports", function () { diff --git a/packages/protoplugin-test/src/transpile.test.ts b/packages/protoplugin-test/src/transpile.test.ts index b90beaab5..608f9e954 100644 --- a/packages/protoplugin-test/src/transpile.test.ts +++ b/packages/protoplugin-test/src/transpile.test.ts @@ -41,8 +41,11 @@ function transpile( if (!file) { throw new Error(`did not find file ${filename}`); } - const content = file.content ?? ""; - return content.trim().split("\n"); + let content = file.content ?? ""; + if (content.endsWith("\n")) { + content = content.slice(0, -1); // trim final newline so we don't return an extra line + } + return content.split("\n"); }; } diff --git a/packages/protoplugin/src/create-es-plugin.ts b/packages/protoplugin/src/create-es-plugin.ts index 399a402fc..a30665477 100644 --- a/packages/protoplugin/src/create-es-plugin.ts +++ b/packages/protoplugin/src/create-es-plugin.ts @@ -12,11 +12,16 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { createSchema, Schema, toResponse } from "./ecmascript/schema.js"; +import { createSchema, Schema } from "./ecmascript/schema.js"; import type { FileInfo } from "./ecmascript/generated-file.js"; import type { Plugin } from "./plugin.js"; import { transpile } from "./ecmascript/transpile.js"; import { parseParameter } from "./ecmascript/parameter.js"; +import { + CodeGeneratorResponse, + CodeGeneratorResponse_Feature, + protoInt64, +} from "@bufbuild/protobuf"; interface PluginInit { /** @@ -119,7 +124,7 @@ export function createEcmaScriptPlugin(init: PluginInit): Plugin { (targetJs && !init.generateJs) || (targetDts && !init.generateDts) ) { - schema.prepareGenerate("module"); + schema.prepareGenerate("ts"); init.generateTs(schema, "ts"); // Save off the generated TypeScript files so that we can pass these @@ -137,7 +142,7 @@ export function createEcmaScriptPlugin(init: PluginInit): Plugin { if (targetJs) { if (init.generateJs) { - schema.prepareGenerate(parameter.jsImportStyle); + schema.prepareGenerate("js"); init.generateJs(schema, "js"); } else { transpileJs = true; @@ -146,7 +151,7 @@ export function createEcmaScriptPlugin(init: PluginInit): Plugin { if (targetDts) { if (init.generateDts) { - schema.prepareGenerate("module"); + schema.prepareGenerate("dts"); init.generateDts(schema, "dts"); } else { transpileDts = true; @@ -184,3 +189,17 @@ export function createEcmaScriptPlugin(init: PluginInit): Plugin { }, }; } + +function toResponse(files: FileInfo[]): CodeGeneratorResponse { + return new CodeGeneratorResponse({ + supportedFeatures: protoInt64.parse( + CodeGeneratorResponse_Feature.PROTO3_OPTIONAL, + ), + file: files.map((f) => { + if (f.preamble !== undefined) { + f.content = f.preamble + "\n" + f.content; + } + return f; + }), + }); +} diff --git a/packages/protoplugin/src/ecmascript/gencommon.ts b/packages/protoplugin/src/ecmascript/gencommon.ts index f4184cfa7..8a517b4ed 100644 --- a/packages/protoplugin/src/ecmascript/gencommon.ts +++ b/packages/protoplugin/src/ecmascript/gencommon.ts @@ -15,14 +15,8 @@ import type { DescComments } from "@bufbuild/protobuf"; import { codegenInfo, - DescEnum, - DescEnumValue, DescField, DescFile, - DescMessage, - DescMethod, - DescOneof, - DescService, Edition, LongType, ScalarType, @@ -95,90 +89,6 @@ export function makeFilePreamble( return trimSuffix(builder.join(""), "\n"); } -export function createJsDocBlock(text: string, indentation = ""): Printable { - if (text.trim().length == 0) { - return []; - } - let lines = text.split("\n"); - if (lines.length === 0) { - return []; - } - lines = lines.map((l) => l.split("*/").join("*\\/")); - lines = lines.map((l) => (l.length > 0 ? " " + l : l)); - // prettier-ignore - return [ - `${indentation}/**\n`, - ...lines.map((l) => `${indentation} *${l}\n`), - `${indentation} */` - ]; -} - -export function makeJsDoc( - desc: - | DescEnum - | DescEnumValue - | DescMessage - | DescOneof - | DescField - | DescService - | DescMethod, - indentation = "", -): Printable { - const comments = desc.getComments(); - let text = ""; - if (comments.leading !== undefined) { - text += comments.leading; - if (text.endsWith("\n")) { - text = text.substring(0, text.length - 1); - } - } - if (comments.trailing !== undefined) { - if (text.length > 0) { - text += "\n\n"; - } - text += comments.trailing; - if (text.endsWith("\n")) { - text = text.substring(0, text.length - 1); - } - } - if (text.length > 0) { - text += "\n\n"; - } - text = text - .split("\n") - .map((line) => (line.startsWith(" ") ? line.substring(1) : line)) - .join("\n"); - - switch (desc.kind) { - case "enum_value": - text += `@generated from enum value: ${desc.declarationString()};`; - break; - case "field": - text += `@generated from field: ${desc.declarationString()};`; - break; - default: - text += `@generated from ${desc.toString()}`; - break; - } - let deprecated = desc.deprecated; - switch (desc.kind) { - case "enum": - case "message": - case "service": - deprecated = deprecated || (desc.file.proto.options?.deprecated ?? false); - break; - default: - break; - } - if (deprecated) { - text += "\n@deprecated"; - } - if (text.length > 0) { - return createJsDocBlock(text, indentation); - } - return []; -} - /** * Returns an expression for the TypeScript typing of a field, * and whether the property should be optional. diff --git a/packages/protoplugin/src/ecmascript/generated-file.ts b/packages/protoplugin/src/ecmascript/generated-file.ts index 100b86654..62e9ddca6 100644 --- a/packages/protoplugin/src/ecmascript/generated-file.ts +++ b/packages/protoplugin/src/ecmascript/generated-file.ts @@ -12,7 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License. -import type { DescEnum, DescFile, DescMessage } from "@bufbuild/protobuf"; +import type { + AnyDesc, + DescEnum, + DescExtension, + DescFile, + DescMessage, +} from "@bufbuild/protobuf"; import type { ImportSymbol } from "./import-symbol.js"; import { createImportSymbol } from "./import-symbol.js"; import { literalString, makeFilePreamble } from "./gencommon.js"; @@ -20,6 +26,8 @@ import type { RuntimeImports } from "./runtime-imports.js"; import { makeImportPathRelative } from "./import-path.js"; import type { ExportDeclaration } from "./export-declaration.js"; import { createExportDeclaration } from "./export-declaration.js"; +import type { JSDocBlock } from "./jsdoc.js"; +import { createJsDocBlock } from "./jsdoc.js"; /** * All types that can be passed to GeneratedFile.print() @@ -32,6 +40,7 @@ export type Printable = | Uint8Array | ImportSymbol | ExportDeclaration + | JSDocBlock | DescMessage | DescEnum | Printable[]; @@ -83,10 +92,31 @@ export interface GeneratedFile { print(fragments: TemplateStringsArray, ...printables: Printable[]): void; /** - * Reserves an identifier in this file. + * @deprecated Please use createImportSymbol() from @bufbuild/protoplugin/ecmascript instead */ export(name: string): ImportSymbol; + /** + * Create a string literal. + */ + string(string: string): Printable; + + /** + * Create a JSDoc comment block with the given text. Line breaks and white-space + * stay intact. + */ + jsDoc(text: string, indentation?: string): JSDocBlock; + + /** + * Create a JSDoc comment block for the given message, enumeration, or other + * descriptor. The comment block will contain the original comments from the + * protobuf source, and annotations such as `@generated from message MyMessage`. + */ + jsDoc( + desc: Exclude, + indentation?: string, + ): JSDocBlock; + /** * Create a printable export statement. For example: * @@ -198,6 +228,12 @@ export function createGeneratedFile( exportDecl(declaration, name) { return createExportDeclaration(declaration, name); }, + string(string) { + return literalString(string); + }, + jsDoc(textOrDesc, indentation) { + return createJsDocBlock(textOrDesc, indentation); + }, import(typeOrName: DescMessage | DescEnum | string, from?: string) { if (typeof typeOrName == "string") { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion @@ -338,6 +374,9 @@ function printableToEl( case "es_symbol": el.push(p); break; + case "es_jsdoc": + el.push(p.toString()); + break; case "es_export_decl": el.push({ kind: "es_export_stmt", diff --git a/packages/protoplugin/src/ecmascript/index.ts b/packages/protoplugin/src/ecmascript/index.ts index 3879f12fc..289015c12 100644 --- a/packages/protoplugin/src/ecmascript/index.ts +++ b/packages/protoplugin/src/ecmascript/index.ts @@ -12,23 +12,30 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { codegenInfo } from "@bufbuild/protobuf"; +import { + AnyDesc, + codegenInfo, + DescExtension, + DescFile, +} from "@bufbuild/protobuf"; +import { Printable } from "./generated-file.js"; +import { createJsDocBlock as createJsDocBlockInternal } from "./jsdoc.js"; +import { literalString as literalStringInternal } from "./gencommon.js"; + export { reifyWkt } from "./reify-wkt.js"; export type { Target } from "./target.js"; export type { Schema } from "./schema.js"; export type { RuntimeImports } from "./runtime-imports.js"; export type { GeneratedFile, FileInfo, Printable } from "./generated-file.js"; export type { ImportSymbol } from "./import-symbol.js"; +export { createImportSymbol } from "./import-symbol.js"; export const { localName } = codegenInfo; export { - createJsDocBlock, getFieldExplicitDefaultValue, getFieldIntrinsicDefaultValue, getFieldTyping, - makeJsDoc, - literalString, } from "./gencommon.js"; export { @@ -36,3 +43,27 @@ export { findCustomMessageOption, findCustomEnumOption, } from "./custom-options.js"; + +/** + * @deprecated Please use GeneratedFile.string() instead + */ +export function literalString(value: string): string { + return literalStringInternal(value); +} + +/** + * @deprecated Please use GeneratedFile.jsDoc() instead + */ +export function makeJsDoc( + desc: Exclude, + indentation = "", +): Printable { + return createJsDocBlockInternal(desc, indentation).toString(); +} + +/** + * @deprecated Please use GeneratedFile.jsDoc() instead + */ +export function createJsDocBlock(text: string, indentation = ""): Printable { + return createJsDocBlockInternal(text, indentation).toString(); +} diff --git a/packages/protoplugin/src/ecmascript/jsdoc.ts b/packages/protoplugin/src/ecmascript/jsdoc.ts new file mode 100644 index 000000000..1517a9158 --- /dev/null +++ b/packages/protoplugin/src/ecmascript/jsdoc.ts @@ -0,0 +1,102 @@ +// Copyright 2021-2023 Buf Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import type { AnyDesc, DescExtension, DescFile } from "@bufbuild/protobuf"; + +export type JSDocBlock = { + readonly kind: "es_jsdoc"; + text: string; + indentation?: string; + toString(): string; +}; + +export function createJsDocBlock( + textOrDesc: string | Exclude, + indentation?: string, +): JSDocBlock { + const text = + typeof textOrDesc == "string" ? textOrDesc : createTextForDesc(textOrDesc); + return { + kind: "es_jsdoc", + text, + indentation, + toString(): string { + if (text.trim().length == 0) { + return ""; + } + let lines = text.split("\n"); + if (lines.length === 0) { + return ""; + } + lines = lines.map((l) => l.split("*/").join("*\\/")); + lines = lines.map((l) => (l.length > 0 ? " " + l : l)); + const i = indentation ?? ""; + return [`${i}/**\n`, ...lines.map((l) => `${i} *${l}\n`), `${i} */`].join( + "", + ); + }, + }; +} + +function createTextForDesc(desc: Exclude) { + const comments = desc.getComments(); + let text = ""; + if (comments.leading !== undefined) { + text += comments.leading; + if (text.endsWith("\n")) { + text = text.substring(0, text.length - 1); + } + } + if (comments.trailing !== undefined) { + if (text.length > 0) { + text += "\n\n"; + } + text += comments.trailing; + if (text.endsWith("\n")) { + text = text.substring(0, text.length - 1); + } + } + if (text.length > 0) { + text += "\n\n"; + } + text = text + .split("\n") + .map((line) => (line.startsWith(" ") ? line.substring(1) : line)) + .join("\n"); + switch (desc.kind) { + case "enum_value": + text += `@generated from enum value: ${desc.declarationString()};`; + break; + case "field": + text += `@generated from field: ${desc.declarationString()};`; + break; + default: + text += `@generated from ${desc.toString()}`; + break; + } + let deprecated = desc.deprecated; + switch (desc.kind) { + case "enum": + case "message": + case "service": + deprecated = deprecated || (desc.file.proto.options?.deprecated ?? false); + break; + default: + break; + } + if (deprecated) { + text += "\n@deprecated"; + } + return text; +} diff --git a/packages/protoplugin/src/ecmascript/schema.ts b/packages/protoplugin/src/ecmascript/schema.ts index be1794b97..8727efc4d 100644 --- a/packages/protoplugin/src/ecmascript/schema.ts +++ b/packages/protoplugin/src/ecmascript/schema.ts @@ -19,13 +19,7 @@ import type { DescMessage, DescriptorSet, } from "@bufbuild/protobuf"; -import { - CodeGeneratorResponse, - CodeGeneratorResponse_Feature, - codegenInfo, - createDescriptorSet, - protoInt64, -} from "@bufbuild/protobuf"; +import { codegenInfo, createDescriptorSet } from "@bufbuild/protobuf"; import type { FileInfo, GeneratedFile, @@ -80,12 +74,12 @@ export interface Schema { interface SchemaController extends Schema { getFileInfo: () => FileInfo[]; - prepareGenerate(jsImportStyle: "module" | "legacy_commonjs"): void; + prepareGenerate(target: Target): void; } export function createSchema( request: CodeGeneratorRequest, - parameter: Omit, + parameter: ParsedParameter, pluginName: string, pluginVersion: string, ): SchemaController { @@ -101,7 +95,7 @@ export function createSchema( ); return createImportSymbol(name, from); }; - let jsImportStyle: "module" | "legacy_commonjs" | undefined; + let target: Target | undefined; const generatedFiles: GeneratedFileController[] = []; return { targets: parameter.targets, @@ -110,7 +104,7 @@ export function createSchema( files: filesToGenerate, allFiles: descriptorSet.files, generateFile(name) { - if (jsImportStyle === undefined) { + if (target === undefined) { throw new Error( "prepareGenerate() must be called before generateFile()", ); @@ -118,7 +112,7 @@ export function createSchema( const genFile = createGeneratedFile( name, deriveImportPath(name), - jsImportStyle, + target === "js" ? parameter.jsImportStyle : "module", // ts and dts always use import/export, only js may use commonjs (importPath: string) => rewriteImportPath( importPath, @@ -142,26 +136,12 @@ export function createSchema( .map((f) => f.getFileInfo()) .filter((fi) => parameter.keepEmptyFiles || fi.content.length > 0); }, - prepareGenerate(newJsImportStyle) { - jsImportStyle = newJsImportStyle; + prepareGenerate(newTarget) { + target = newTarget; }, }; } -export function toResponse(files: FileInfo[]): CodeGeneratorResponse { - return new CodeGeneratorResponse({ - supportedFeatures: protoInt64.parse( - CodeGeneratorResponse_Feature.PROTO3_OPTIONAL, - ), - file: files.map((f) => { - if (f.preamble !== undefined) { - f.content = f.preamble + "\n" + f.content; - } - return f; - }), - }); -} - function findFilesToGenerate( descriptorSet: DescriptorSet, request: CodeGeneratorRequest, diff --git a/packages/protoplugin/src/index.ts b/packages/protoplugin/src/index.ts index 856fda3a3..d26e8043a 100644 --- a/packages/protoplugin/src/index.ts +++ b/packages/protoplugin/src/index.ts @@ -12,7 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. +import { Schema as SchemaInternal } from "./ecmascript/schema.js"; export { Plugin } from "./plugin.js"; -export { Schema } from "./ecmascript/schema.js"; export { runNodeJs } from "./run-node.js"; export { createEcmaScriptPlugin } from "./create-es-plugin.js"; + +/** + * @deprecated Please use Schema from @bufbuild/protoplugin/ecmascript instead + */ +export type Schema = SchemaInternal;