Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/bun-types.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,16 @@ on:
- "packages/bun-types/**"
- "test/integration/bun-types/**"
- "src/cli/init/tsconfig.default.json"
- "src/**/*.bind.ts"
- "src/codegen/**"
- ".github/workflows/bun-types.yml"
pull_request:
paths:
- "packages/bun-types/**"
- "test/integration/bun-types/**"
- "src/cli/init/tsconfig.default.json"
- "src/**/*.bind.ts"
- "src/codegen/**"
- ".github/workflows/bun-types.yml"

env:
Expand Down
3 changes: 2 additions & 1 deletion docs/project/bindgen.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ not compile.

To reach the function:

- from `src/js`: `$bindgen("bindgen_test.bind.ts", "add")`, which is typed from the schema;
- from `src/js`: `$bindgen("bindgen_test.bind.ts", "add")`, which is typed from the schema. An argument's type is what it takes, so one that coerces (`.loose`, `.wrap`, `.enforceRange()`) is `any`;
- from a C++ static property table: `Bun::Bindgen::Generated::BindgenTest::jsAdd` in `GeneratedBindgenTest.h`.

### Namespaces
Expand Down Expand Up @@ -560,3 +560,4 @@ thread when the work completes.
## Tests

- `test/internal/bindgen.test.ts`: conversions with hostile inputs, through `src/jsc/bindgen_test.bind.ts`. A schema named `*_test.bind.ts` is only part of a build with assertions (a debug build, CI's ASAN lane), so nothing it binds ships; `$bindgen` of one is `undefined` elsewhere, and its Rust module is `#[cfg(debug_assertions)]`. What that schema does not use is covered by the suites of the APIs that do.
- `test/integration/bun-types/bun-types.test.ts`: `packages/bun-types` promises nothing that a schema breaks. Every call the published types allow has arguments the schema takes and options its dictionaries have, and every result they name is one it gives. A new schema file is listed in `PUBLISHED_AS` there, with what the published types call its exports.
12 changes: 7 additions & 5 deletions packages/bun-types/bun.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2884,10 +2884,7 @@ declare module "bun" {
* @param data The data to hash.
* @param seed The seed to use.
*/
const hash: ((
data: string | ArrayBufferView | ArrayBuffer | SharedArrayBuffer,
seed?: number | bigint,
) => number | bigint) &
const hash: ((data: string | ArrayBufferView | ArrayBuffer | SharedArrayBuffer, seed?: number | bigint) => bigint) &
Hash;

interface Hash {
Expand Down Expand Up @@ -5405,7 +5402,7 @@ declare module "bun" {
*
* To close the file, set the array to `null`; it is garbage collected eventually.
*/
function mmap(path: PathLike, opts?: MMapOptions): Uint8Array<ArrayBuffer>;
function mmap(path: string, opts?: MMapOptions): Uint8Array<ArrayBuffer>;

/**
* Write to stdout
Expand Down Expand Up @@ -5863,6 +5860,11 @@ declare module "bun" {
* This class only exists in types
*/
abstract class CryptoHashInterface<T> {
/**
* The number of bytes the hash produces
*/
readonly byteLength: number;

/**
* Update the hash with data
*
Expand Down
38 changes: 23 additions & 15 deletions src/codegen/bindgen-lib-internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ export abstract class Type {

/** The C++ tag type whose `WebCore::Converter` turns a JSValue into a C++ value. */
abstract readonly idlType: string;
/** TypeScript spelling. */
/** TypeScript spelling of the values it takes: `any` where the conversion coerces. */
abstract readonly tsType: string;

/** How `IDLNullable<this>::ImplementationType` says "null". */
Expand Down Expand Up @@ -332,7 +332,7 @@ class PrimitiveType extends Type {

export class BoolType extends PrimitiveType {
constructor(idlType = "Bun::Bindgen::IDLStrictBoolean", valueKinds: ValueKinds = ["boolean"]) {
super(idlType, "boolean", "bool", 1, valueKinds);
super(idlType, valueKinds === "any" ? "any" : "boolean", "bool", 1, valueKinds);
}
/** `ToBoolean`: any value is accepted. */
get loose(): BoolType {
Expand Down Expand Up @@ -362,12 +362,13 @@ export class IntegerType extends PrimitiveType {
info: IntegerInfo,
conversion: "Strict" | "Loose" | `${string}::${string}` = "Strict",
allowBigInt = false,
coerces = conversion !== "Strict",
) {
super(
conversion === "Strict" || conversion === "Loose"
? `Bun::Bindgen::IDL${conversion}Integer<${info.cpp}${allowBigInt ? ", Bun::Bindgen::AllowBigInt::Yes" : ""}>`
: conversion,
allowBigInt ? "number | bigint" : "number",
coerces ? "any" : allowBigInt ? "number | bigint" : "number",
info.rust,
info.size,
conversion !== "Strict" ? "any" : allowBigInt ? ["number", "bigint"] : ["number"],
Expand Down Expand Up @@ -452,7 +453,7 @@ export class RangeType extends IntegerType {
);
assert(min >= Number(info.min) && max <= Number(info.max), `[${min}, ${max}] does not fit ${info.rust}`);
const tag = modifier === "enforceRange" ? "IDLEnforceRange" : "IDLValidateInteger";
super(info, `Bun::Bindgen::${tag}<${info.cpp}, ${min}, ${max}>`);
super(info, `Bun::Bindgen::${tag}<${info.cpp}, ${min}, ${max}>`, false, modifier === "enforceRange");
this.modifier = modifier;
this.#range = [min, max];
}
Expand Down Expand Up @@ -492,34 +493,37 @@ export class DoubleType extends PrimitiveType {
}
/** `ToNumber`: WebIDL's `unrestricted double`. */
get loose(): LooseDoubleType {
return new LooseDoubleType("WebCore::IDLUnrestrictedDouble", "number", "any");
return new LooseDoubleType("WebCore::IDLUnrestrictedDouble", "any", "any");
}
}

export class LooseDoubleType extends DoubleType {
/** `ToNumeric`: a BigInt too, as the double nearest to it, for an argument whose type is `number | bigint`. */
get allowBigInt(): DoubleType {
return new DoubleType("Bun::Bindgen::IDLLooseDoubleOrBigInt", "number | bigint", "any");
return new DoubleType("Bun::Bindgen::IDLLooseDoubleOrBigInt", "any", "any");
}
}

/** Only as a union alternative or a return type. */
export class UnitType extends Type {
readonly idlType: string;
readonly tsType: string;
constructor(tsType: "null" | "undefined") {
readonly unit: "null" | "undefined";
constructor(unit: "null" | "undefined") {
super();
this.tsType = tsType;
this.idlType = tsType === "null" ? "Bun::Bindgen::IDLStrictNull" : "";
this.unit = unit;
this.idlType = unit === "null" ? "Bun::Bindgen::IDLStrictNull" : "";
}
get tsType(): string {
return this.unit === "null" ? "null | undefined" : "undefined";
}
get valueKinds(): ValueKinds {
return this.tsType === "null" ? ["undefined", "null"] : ["undefined"];
return this.unit === "null" ? ["undefined", "null"] : ["undefined"];
}
get takesUndefined(): boolean {
return true;
}
cppConvert(): string {
throw new TypeError(`b.${this.tsType} is only an alternative of a union`);
throw new TypeError(`b.${this.unit} is only an alternative of a union`);
}
layout(): Layout {
return { size: 1, align: 1 };
Expand All @@ -534,7 +538,7 @@ export class UnitType extends Type {
return "()";
}
rustToJS(value: string): string {
assert(this.tsType === "undefined", "leave `returns` out, or return a nullable type");
assert(this.unit === "undefined", "leave `returns` out, or return a nullable type");
return `bindgen::to_js::undefined(heap, ${value})`;
}
}
Expand All @@ -544,13 +548,15 @@ export class UnitType extends Type {
/** An owned `bun_core::String` sharing the JS string's `StringImpl`. */
export class StringType extends Type {
readonly idlType: string;
readonly tsType = "string";
readonly #valueKinds: ValueKinds;
constructor(idlType = "Bun::Bindgen::IDLStrictString", valueKinds: ValueKinds = ["string"]) {
super();
this.idlType = idlType;
this.#valueKinds = valueKinds;
}
get tsType(): string {
return this.valueKinds === "any" ? "any" : "string";
}
get valueKinds(): ValueKinds {
return this.#valueKinds;
}
Expand Down Expand Up @@ -626,11 +632,13 @@ export class OwnedUtf8Type extends StringType {
/** UTF-8 bytes for the duration of the call: `Bun::Bindgen::JSStringUTF8View`. */
export class Utf8Type extends Type {
readonly isLoose: boolean;
readonly tsType = "string";
constructor(isLoose: boolean) {
super();
this.isLoose = isLoose;
}
get tsType(): string {
return this.isLoose ? "any" : "string";
}
get idlType(): string {
return `Bun::Bindgen::IDL${this.isLoose ? "Loose" : "Strict"}UTF8String`;
}
Expand Down
2 changes: 2 additions & 0 deletions src/runtime/api/BunObject.bind.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ export const ZlibCompressionOptions = b.dictionary(
windowBits: b.f64.loose.allowBigInt.optional,
library: b.string.utf8.nullable,
level: b.f64.loose.allowBigInt.optional,
memLevel: b.f64.loose.allowBigInt.optional,
strategy: b.f64.loose.allowBigInt.optional,
Comment thread
dylan-conway marked this conversation as resolved.
Resolved
},
{ allowArray: true },
);
Expand Down
9 changes: 8 additions & 1 deletion src/runtime/api/BunObject.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1873,7 +1873,7 @@
pub(crate) fn gzip_sync<'a>(
heap: &JsHeap<'a>,
data: &[u8],
options: ZlibCompressionOptions<'a>,

Check failure on line 1876 in src/runtime/api/BunObject.rs

View workflow job for this annotation

GitHub Actions / cargo clippy

this argument (80 byte) is passed by value, but might be more efficient if passed by reference (limit: 64 byte)
) -> BufferResult<'a> {
gzip_or_deflate_sync(heap, data, options, true)
}
Expand All @@ -1890,7 +1890,7 @@
pub(crate) fn deflate_sync<'a>(
heap: &JsHeap<'a>,
data: &[u8],
options: ZlibCompressionOptions<'a>,

Check failure on line 1893 in src/runtime/api/BunObject.rs

View workflow job for this annotation

GitHub Actions / cargo clippy

this argument (80 byte) is passed by value, but might be more efficient if passed by reference (limit: 64 byte)
) -> BufferResult<'a> {
gzip_or_deflate_sync(heap, data, options, false)
}
Expand Down Expand Up @@ -1994,7 +1994,7 @@
fn gzip_or_deflate_sync<'a>(
heap: &JsHeap<'a>,
data: &[u8],
options: ZlibCompressionOptions<'a>,

Check failure on line 1997 in src/runtime/api/BunObject.rs

View workflow job for this annotation

GitHub Actions / cargo clippy

this argument (80 byte) is passed by value, but might be more efficient if passed by reference (limit: 64 byte)
is_gzip: bool,
) -> BufferResult<'a> {
let level = options.level.map_or(6, |level| level as i32);
Expand All @@ -2004,14 +2004,21 @@
// `init` reserves `deflateBound` of the input.
let mut list: Vec<u8> = Vec::new();

let defaults = zlib::Options::default();
let mut reader = zlib::ZlibCompressorArrayList::init(
data,
&mut list,
zlib::Options {
window_bits: 15,
Comment thread
dylan-conway marked this conversation as resolved.
Resolved
gzip: is_gzip,
level,
..Default::default()
mem_level: options
.mem_level
.map_or(defaults.mem_level, |mem_level| mem_level as i32),
Comment thread
dylan-conway marked this conversation as resolved.
Resolved
strategy: options
.strategy
.map_or(defaults.strategy, |strategy| strategy as i32),
Comment thread
dylan-conway marked this conversation as resolved.
Resolved
..defaults
},
)
.map_err(|err| init_error(heap, err))?;
Expand Down
149 changes: 149 additions & 0 deletions test/integration/bun-types/bun-types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,108 @@ function checkForEmptyInterfaces(program: ts.Program) {
return empties;
}

/**
* What the published types call the exports of each schema of the bindgen (docs/project/bindgen.mdx).
* `null`: nothing, since JavaScript wraps them or only tests reach them.
*/
const PUBLISHED_AS: Record<string, string | null> = {
"BunObject.bind.ts": "typeof Bun & { braces: typeof Bun.$.braces; shellEscape: typeof Bun.$.escape }",
"Crypto.bind.ts": "typeof Bun",
"CryptoHasher.bind.ts": "typeof Bun",
"DevServer.bind.ts": null,
"Glob.bind.ts": "typeof Bun",
"HashObject.bind.ts": "typeof Bun.hash & { wyhash: typeof Bun.hash }",
"JSON5Object.bind.ts": "typeof Bun.JSON5",
"JSONCObject.bind.ts": "typeof Bun.JSONC",
"NodeModuleModule.bind.ts": null,
"PasswordObject.bind.ts": "typeof Bun.password",
"SemverObject.bind.ts": "typeof Bun.semver",
"TOMLObject.bind.ts": "typeof Bun.TOML",
"UnsafeObject.bind.ts": "typeof Bun.unsafe",
"XMLObject.bind.ts": "typeof Bun.XML",
"YAMLObject.bind.ts": "typeof Bun.YAML",
"bindgen_test.bind.ts": null,
"csrf_jsc.bind.ts": "typeof Bun.CSRF",
"dns.bind.ts": "typeof Bun.dns",
"fmt_jsc.bind.ts": null,
"node_os.bind.ts": null,
};

/**
* Where the published types promise what the schema does not do: a call they allow has an argument
* the schema refuses or an option its dictionary does not have, so nothing reads it, or a result
* they name is not one the schema gives. They may be narrower than it,
* which says "a buffer" of a `Uint8Array`, but not by leaving out `null`.
*/
function promisesTheSchemaBreaks(checker: ts.TypeChecker, published: ts.Type, schema: ts.Type) {
const broken: string[] = [];
const compared = new Map<ts.Type, Set<ts.Type>>();
const show = (type: ts.Type) => checker.typeToString(type, undefined, ts.TypeFormatFlags.NoTruncation);
const generated = schema.symbol.declarations![0].getSourceFile();
const isGenerated = (type: ts.Type) =>
!!type.symbol?.declarations?.some(declaration => declaration.getSourceFile() === generated);

function refusal(allowed: ts.Signature, taken: ts.Signature) {
for (const [i, parameter] of taken.parameters.entries()) {
// `(...args: any[]) => any`, which is all that the schema says of a builtin.
if ((parameter.valueDeclaration as ts.ParameterDeclaration).dotDotDotToken) break;
const argument = allowed.parameters[i];
const given = argument ? checker.getTypeOfSymbol(argument) : checker.getUndefinedType();
const takes = checker.getTypeOfSymbol(parameter);
if (!checker.isTypeAssignableTo(given, takes))
return `${parameter.name} takes ${show(takes)}, not ${show(given)}`;
const dictionaries = (takes.isUnion() ? takes.types : [takes]).filter(isGenerated);
const unread = (given.isUnion() ? given.types : [given])
.flatMap(type => (dictionaries.length > 0 && type.flags & ts.TypeFlags.Object ? type.getProperties() : []))
.filter(member => !dictionaries.some(dictionary => dictionary.getProperty(member.name)));
if (unread.length > 0)
return `${parameter.name} has no ${[...new Set(unread.map(member => member.name))].join(", ")}`;
}
return null;
}

function compareSignatures(path: string, allowed: readonly ts.Signature[], taken: readonly ts.Signature[]) {
if (taken.length === 0) return;
Comment thread
dylan-conway marked this conversation as resolved.
Resolved
if (allowed.length === 0) broken.push(`${path} is not published as a function`);
for (const signature of allowed) {
const refusals = taken.map(candidate => refusal(signature, candidate));
const match = taken[refusals.indexOf(null)];
if (match) compare(`${path}()`, signature.getReturnType(), match.getReturnType());
else broken.push(`${path}${checker.signatureToString(signature)}: ${refusals.join(", or ")}`);
}
}

function compare(path: string, published: ts.Type, schema: ts.Type) {
const calls = schema.getCallSignatures();
const constructs = schema.getConstructSignatures();
if (calls.length === 0 && constructs.length === 0 && !isGenerated(schema)) {
const promisesNothing = published.flags & ts.TypeFlags.Void;
const givesNull = schema.isUnion() && schema.types.some(type => type.flags & ts.TypeFlags.Null);
if (
!promisesNothing &&
(!checker.isTypeAssignableTo(published, schema) ||
(givesNull && !checker.isTypeAssignableTo(checker.getNullType(), published)))
) {
broken.push(`${path} is ${show(schema)}, not ${show(published)}`);
}
return;
}
if (compared.get(published)?.has(schema)) return;
compared.set(published, (compared.get(published) ?? new Set()).add(schema));
compareSignatures(path, published.getCallSignatures(), calls);
compareSignatures(`new ${path}`, published.getConstructSignatures(), constructs);
for (const member of schema.getProperties()) {
Comment thread
dylan-conway marked this conversation as resolved.
Resolved
const promised = checker.getPropertyOfType(published, member.name);
const memberPath = path ? `${path}.${member.name}` : member.name;
if (promised) compare(memberPath, checker.getTypeOfSymbol(promised), checker.getTypeOfSymbol(member));
else broken.push(`${memberPath} is not published`);
}
}

compare("", published, schema);
return broken;
}

afterAll(async () => {
if (TEMP_DIR) {
if (Bun.env.TYPES_INTEGRATION_TEST_KEEP_TEMP_DIR === "true") {
Expand Down Expand Up @@ -335,6 +437,53 @@ describe("@types/bun integration test", () => {
});
});

test.skipIf(isDebug)("promises nothing that a schema of the bindgen breaks", async () => {
const dir = join(TEMP_DIR, "bindgen");
const schemas = await Array.fromAsync(
new Bun.Glob("src/**/*.bind.ts").scan({ cwd: BUN_REPO_ROOT, absolute: true }),
Comment thread
dylan-conway marked this conversation as resolved.
Resolved
);
await $`${bunExe()} src/codegen/bindgen.ts --sources=${schemas.map(file => `bun=${file}`).join(",")} --codegen-path=${dir} --types-path=${dir}`
.env(bunEnv)
.quiet();
const pairs = join(dir, "published.ts");
await Bun.write(
pairs,
`export type Published = {\n${Object.entries(PUBLISHED_AS)
.map(([file, type]) => ` ${JSON.stringify(file)}: ${type};\n`)
.join("")}};\nexport type Schemas = $Bindgen.Exports;\n`,
);
const program = ts.createProgram({
rootNames: [join(BASE_FIXTURE_DIR, "node_modules", "bun-types", "index.d.ts"), join(dir, "Bindgen.d.ts"), pairs],
options: { ...DEFAULT_COMPILER_OPTIONS, types: [], skipLibCheck: false },
});
const source = program.getSourceFile(pairs)!;
// A name that does not resolve is a type that everything is assignable to.
for (const file of [source, program.getSourceFile(join(dir, "Bindgen.d.ts"))!]) {
expect(ts.getPreEmitDiagnostics(program, file).map(diagnostic => diagnostic.messageText)).toEqual([]);
}

const checker = program.getTypeChecker();
const [published, generated] = source.statements.map(statement => checker.getTypeAtLocation(statement));
expect(
generated
.getProperties()
.map(file => file.name)
.sort(),
).toEqual(Object.keys(PUBLISHED_AS).sort());
Comment thread
dylan-conway marked this conversation as resolved.
Resolved
expect(
generated
.getProperties()
.filter(file => PUBLISHED_AS[file.name])
.flatMap(file =>
promisesTheSchemaBreaks(
checker,
checker.getTypeOfSymbol(published.getProperty(file.name)!),
checker.getTypeOfSymbol(file),
).map(promise => `${file.name}: ${promise}`),
),
).toEqual([]);
});

// The fixture depends on typescript@latest, so this is the current stable release:
// since 7.0 that is the native (Go-based) compiler, which does not expose a JS
// compiler API, so unlike the tests above we write a real tsconfig and spawn the CLI.
Expand Down
Loading
Loading