Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support Bindgen opt-in list #6639

Merged
merged 12 commits into from
Jun 16, 2023
4 changes: 3 additions & 1 deletion bindgen/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ set_property(TARGET BindgenSpecJsonSchema PROPERTY NPX "${NPX}")

function(bindgen)
set(options)
set(oneValueArgs TEMPLATE OUTDIR)
set(oneValueArgs TEMPLATE OUTDIR OPTIN)
set(multiValueArgs OUTPUTS SPECS SOURCES)
cmake_parse_arguments(PARSE_ARGV 0 BINDGEN "${options}" "${oneValueArgs}" "${multiValueArgs}")

Expand Down Expand Up @@ -64,6 +64,7 @@ function(bindgen)
WORKING_DIRECTORY ${CMAKE_CURRENT_FUNCTION_LIST_DIR}
COMMAND ${NPX} tsx -- "${CMAKE_CURRENT_FUNCTION_LIST_DIR}/realm-bindgen.ts"
--spec "$<JOIN:${CORE_SPEC_FILE};${BINDGEN_SPECS},;--spec;>"
"$<$<BOOL:${BINDGEN_OPTIN}>:--opt-in;${BINDGEN_OPTIN}>"
--template "${BINDGEN_TEMPLATE}"
--output "${BINDGEN_OUTDIR}"
COMMAND_EXPAND_LISTS
Expand All @@ -78,6 +79,7 @@ function(bindgen)
# from sdk
${BINDGEN_SPECS}
${BINDGEN_SOURCES}
${BINDGEN_OPTIN}
)

foreach(FILE ${BINDGEN_OUTPUTS})
Expand Down
146 changes: 116 additions & 30 deletions bindgen/src/bound-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
////////////////////////////////////////////////////////////////////////////
import { strict as assert } from "assert";

import { Spec, TypeSpec, MethodSpec } from "./spec";
import { OptInSpec, Spec, TypeSpec, MethodSpec } from "./spec";

abstract class TypeBase {
abstract readonly kind: TypeKind;
Expand All @@ -29,31 +29,31 @@ abstract class TypeBase {

// This is mostly because TS doesn't know that Type covers all types derived from TypeBase.
is<Kind extends TypeKind>(kind: Kind): this is Type & { kind: Kind } {
return this.kind == kind;
return this.kind === kind;
}

isOptional(): this is Template & { name: "util::Optional" };
isOptional(type: string): boolean;
isOptional(type?: string): boolean {
return this.isTemplate("util::Optional") && (!type || ("name" in this.args[0] && this.args[0].name == type));
return this.isTemplate("util::Optional") && (!type || ("name" in this.args[0] && this.args[0].name === type));
}

isNullable(): this is Template & { name: "Nullable" };
isNullable(type: string): boolean;
isNullable(type?: string): boolean {
return this.isTemplate("Nullable") && (!type || ("name" in this.args[0] && this.args[0].name == type));
return this.isTemplate("Nullable") && (!type || ("name" in this.args[0] && this.args[0].name === type));
}

isTemplate(): this is Template;
isTemplate<Name extends string>(type: Name): this is Template & { name: Name };
isTemplate(type?: string): boolean {
return this.is("Template") && (!type || this.name == type);
return this.is("Template") && (!type || this.name === type);
}

isPrimitive(): this is Primitive;
isPrimitive<Name extends string>(type: Name): this is Primitive & { name: Name };
isPrimitive(type?: string): boolean {
return this.is("Primitive") && (!type || this.name == type);
return this.is("Primitive") && (!type || this.name === type);
}

isVoid(): this is Primitive & { name: "void" } {
Expand Down Expand Up @@ -172,7 +172,7 @@ export class Func extends TypeBase {
asyncTransform(): Func | undefined {
if (!this.ret.isVoid()) return undefined;
const voidType = this.ret;
if (this.args.length == 0) return undefined;
if (this.args.length === 0) return undefined;
const lastArgType = this.args[this.args.length - 1].type.removeConstRef();
if (!lastArgType.isTemplate("AsyncCallback")) return undefined;

Expand All @@ -196,7 +196,7 @@ export class Func extends TypeBase {
`but got ${lastCbArgType}`,
);
let res: Type = voidType;
if (cb.args.length == 2) {
if (cb.args.length === 2) {
res = cb.args[0].type.removeConstRef();
if (res.isOptional() || res.isNullable()) {
res = res.args[0];
Expand Down Expand Up @@ -263,6 +263,12 @@ export type MethodCallSig = ({ self }: { self: string }, ...args: string[]) => s

export abstract class Method {
isConstructor = false;
/**
* Whether this method is opted in to by the consumer/SDK.
* For this to be true you must pass an opt-in list to the
* binding generator and call `BoundSpec.applyOptInList()`.
*/
isOptedInTo = false;
abstract isStatic: boolean;
constructor(
public on: Class,
Expand Down Expand Up @@ -334,11 +340,37 @@ export class Class extends NamedType {
abstract = false;
base?: Class;
subclasses: Class[] = [];
methods: Method[] = [];
private _methods: { [uniqueName: string]: Method } = {};
sharedPtrWrapped = false;
needsDeref = false;
iterable?: Type;

/**
* Get a new array containing the methods.
* For adding a method onto `this` instance, use `addMethod()`.
*/
get methods() {
return Object.values(this._methods);
}

addMethod(method: Method) {
assert(
!(method.unique_name in this._methods),
`Duplicate unique method name on class '${this.name}': '${method.unique_name}'`,
);
this._methods[method.unique_name] = method;
}

getMethod(uniqueName: string) {
const method = this._methods[uniqueName];
assert(
method,
`Method '${uniqueName}' does not exist on class '${this.name}'. The method in the opt-in list must correspond to a method in the general spec.`,
);

return method;
}

toString() {
return `class ${this.name}`;
}
Expand All @@ -356,16 +388,22 @@ export class Class extends NamedType {
return cls;
}

*decedents(): Iterable<Class> {
*descendants(): Iterable<Class> {
elle-j marked this conversation as resolved.
Show resolved Hide resolved
for (const sub of this.subclasses) {
assert.notEqual(sub, this, `base class loop detected on ${this.name}`);
yield sub;
yield* sub.decedents();
yield* sub.descendants();
}
}
}

export class Field {
/**
* Whether this field is opted in to by the consumer/SDK.
* For this to be true you must pass an opt-in list to the
* binding generator and call `BoundSpec.applyOptInList()`.
*/
isOptedInTo = false;
constructor(
public name: string,
public cppName: string,
Expand All @@ -378,7 +416,30 @@ export class Field {
export class Struct extends NamedType {
readonly kind = "Struct";
cppName!: string;
fields: Field[] = [];
private _fields: { [name: string]: Field } = {};

/**
* Get a new array containing the fields.
* For adding a field onto `this` instance, use `addField()`.
*/
get fields() {
return Object.values(this._fields);
}

addField(field: Field) {
assert(!(field.name in this._fields), `Duplicate field name on record/struct '${this.name}': '${field.name}'.`);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm pretty sure it is impossible[1] to express duplicate fields in the spec file syntax right now, but I suppose it is fine to leave it incase that changes.

[1] You could have duplicates in the spec.yaml file, but the yaml spec requires that keys in a mapping are unique, so such a file isn't valid yaml. And even if the yaml parser doesn't detect that, since it parses into a Plain Old JS Object, any later field will completely replace the prior duplicate, so by the time you get to this code, you won't have any trace of the in-file duplication.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point!

this._fields[field.name] = field;
}

getField(name: string) {
const field = this._fields[name];
assert(
field,
`Field '${name}' does not exist on record/struct '${this.name}'. The field in the opt-in list must correspond to a field in the general spec.`,
);

return field;
}

toString() {
return `struct ${this.name}`;
Expand Down Expand Up @@ -477,6 +538,29 @@ export class BoundSpec {
opaqueTypes: Opaque[] = [];
mixedInfo!: MixedInfo;
types: Record<string, Type> = {};
optInSpec?: OptInSpec;

/**
* Marks methods and fields in the opt-in list as `isOptedInTo` which the
* consumer/SDK then can choose to handle accordingly.
*/
applyOptInList() {
for (const [clsName, clsRaw] of Object.entries(this.optInSpec?.classes ?? {})) {
const boundClass = this.types[clsName];
assert(boundClass instanceof Class);
for (const methodName of clsRaw.methods) {
boundClass.getMethod(methodName).isOptedInTo = true;
}
}

for (const [structName, structRaw] of Object.entries(this.optInSpec?.records ?? {})) {
const boundStruct = this.types[structName];
assert(boundStruct instanceof Struct);
for (const fieldName of structRaw.fields) {
boundStruct.getField(fieldName).isOptedInTo = true;
}
}
}
}

type MixedInfo = {
Expand All @@ -485,15 +569,16 @@ type MixedInfo = {
ctors: Type[];
};

export function bindModel(spec: Spec): BoundSpec {
export function bindModel(spec: Spec, optInSpec?: OptInSpec): BoundSpec {
const templates: Map<string, Spec["templates"][string]> = new Map();
const rootClasses: Class[] = [];

const out = new BoundSpec();
out.optInSpec = optInSpec;

function addType<T extends Type>(name: string, type: T | (new (name: string) => T)) {
assert(!(name in out.types));
if (typeof type == "function") type = new type(name);
if (typeof type === "function") type = new type(name);

out.types[name] = type;
return type;
Expand Down Expand Up @@ -548,7 +633,7 @@ export function bindModel(spec: Spec): BoundSpec {
) {
for (const [name, overloads] of Object.entries(methods)) {
for (const overload of overloads) {
on.methods.push(
on.addMethod(
new OutType(
on,
name,
Expand All @@ -561,7 +646,7 @@ export function bindModel(spec: Spec): BoundSpec {
}
}

// Attach names to instences of Type in types
// Attach names to instances of Type in types
for (const [name, args] of Object.entries(spec.templates)) {
templates.set(name, args);
}
Expand Down Expand Up @@ -606,12 +691,12 @@ export function bindModel(spec: Spec): BoundSpec {
for (const [name, { cppName, fields }] of Object.entries(spec.records)) {
const struct = out.types[name] as Struct;
struct.cppName = cppName ?? name;
struct.fields = Object.entries(fields).map(([name, field]) => {
for (const [name, field] of Object.entries(fields)) {
const type = resolveTypes(field.type);
// Optional and Nullable fields are never required.
const required = field.default === undefined && !(type.isNullable() || type.isOptional());
return new Field(name, field.cppName ?? name, type, required, field.default);
});
struct.addField(new Field(name, field.cppName ?? name, type, required, field.default));
}
}

for (const [name, type] of Object.entries(spec.keyTypes)) {
Expand Down Expand Up @@ -642,23 +727,24 @@ export function bindModel(spec: Spec): BoundSpec {

// Constructors are exported to js as named static methods. The "real" js constructors
// are only used internally for attaching the C++ instance to a JS object.
cls.methods.push(
...Object.entries(raw.constructors).flatMap(([name, rawSig]) => {
const sig = resolveTypes(rawSig);
// Constructors implicitly return the type of the class.
assert(sig.kind == "Func" && sig.ret.isVoid());
sig.ret = cls.sharedPtrWrapped ? new Template("std::shared_ptr", [cls]) : cls;
return new Constructor(cls, name, sig);
}),
);
const constructors = Object.entries(raw.constructors).flatMap(([name, rawSig]) => {
const sig = resolveTypes(rawSig);
// Constructors implicitly return the type of the class.
assert(sig.kind === "Func" && sig.ret.isVoid());
sig.ret = cls.sharedPtrWrapped ? new Template("std::shared_ptr", [cls]) : cls;
return new Constructor(cls, name, sig);
});
for (const constructor of constructors) {
cls.addMethod(constructor);
}

for (const [name, type] of Object.entries(raw.properties ?? {})) {
cls.methods.push(new Property(cls, name, resolveTypes(type)));
cls.addMethod(new Property(cls, name, resolveTypes(type)));
}
}

for (const cls of rootClasses) {
out.classes.push(cls, ...cls.decedents());
out.classes.push(cls, ...cls.descendants());
}

out.mixedInfo = {
Expand Down
4 changes: 3 additions & 1 deletion bindgen/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,14 @@
//
////////////////////////////////////////////////////////////////////////////

import { BoundSpec } from "./bound-model";
import { Formatter } from "./formatter";
import { Outputter } from "./outputter";
import { Spec } from "./spec";

export type TemplateContext = {
spec: Spec;
rawSpec: Spec;
spec: BoundSpec;
/**
* @param path The file path, relative to the output directory.
* @param formatter An optional formatter to run after the template has returned.
Expand Down
11 changes: 7 additions & 4 deletions bindgen/src/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,14 @@
//
////////////////////////////////////////////////////////////////////////////

import { Spec } from "./spec";
import { OptInSpec, Spec } from "./spec";
Copy link
Contributor Author

@elle-j elle-j May 17, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Starting a thread here to facilitate discussing this previous comment from @kraenhansen:

(A) My initial assumption, was that the part of the spec not opted into, would be completely left out of the parsed spec, this would make it simpler for the generator as it doesn't have to check for the isOptedInTo.
I'm curious if you thought about that approach and if yes, why you didn't choose to go that route?

(B) I'm curious if you actively choose "opt-in" instead of "allow", "include" or "expose"?
I personally think isAllowed, isIncluded or isExposed might make equal amount of sense and be more direct.

Regarding (A), we may want to handle the isOptedInTo differently. For instance, currently in our TS template (see RealmJS PR) we add deprecation annotations to the generated types with a message saying what to add to the opt-in list to be able to use it. This way we can still see the available methods and props when working with the binding, rather than switching to the spec to see if it's available. What's your take on this?

Regarding (B), there were some discussions regarding the word "allow" and how it may be ambiguous in the sense that everything in the general spec is essentially allowed (and I guess "exposed" as well) to be used. "included" is also on the table and the current name "opt-in" is definitely easy to change 👍 I thought it made the intent a bit clearer than the other alternatives, but I'm very open to changing it to something that everyone finds suitable 🙂

Copy link
Member

@kraenhansen kraenhansen May 22, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's your take on this?

I agree that seems valuable 👍

a bit clearer than the other alternatives

My main gripe is that "opt-in" is two words and "opted in to" is three 🙈 But ... I'm not strongly opposed 🙂

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the input, I'll leave the conversation unresolved for now to see if others want to weigh in on this 👍

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have a slight preference for "includeList"/"isIncluded", but not enough to overrule the patch author. Just stating my preference in case it comes to a vote.

import { Template } from "./templates";
import { bindModel } from "./bound-model";
import { createOutputDirectory } from "./output-directory";

type GenerateOptions = {
spec: Spec;
rawSpec: Spec;
optInSpec?: OptInSpec;
template: Template;
outputPath: string;
};
Expand All @@ -31,12 +33,13 @@ type GenerateOptions = {
*
* @param options The spec to generate from, the template to apply and the path of the directory to write output
*/
export function generate({ spec, template, outputPath }: GenerateOptions): void {
export function generate({ rawSpec, optInSpec, template, outputPath }: GenerateOptions): void {
// Apply the template
const outputDirectory = createOutputDirectory(outputPath);
try {
template({
spec,
rawSpec,
spec: bindModel(rawSpec, optInSpec),
file: outputDirectory.file.bind(outputDirectory),
});
} finally {
Expand Down
Loading