From 797107a745accca9769c5366d4a046b4052fc4c7 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Fri, 25 Sep 2026 21:20:32 +0100 Subject: [PATCH 01/11] Add design spec: @linkname alias and tsbindgen v1 Co-Authored-By: Claude Opus 5.5 --- .../2026-09-25-tsbindgen-linkname-design.md | 305 ++++++++++++++++++ 1 file changed, 305 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-25-tsbindgen-linkname-design.md diff --git a/docs/superpowers/specs/2026-09-25-tsbindgen-linkname-design.md b/docs/superpowers/specs/2026-09-25-tsbindgen-linkname-design.md new file mode 100644 index 000000000..5f94bee9b --- /dev/null +++ b/docs/superpowers/specs/2026-09-25-tsbindgen-linkname-design.md @@ -0,0 +1,305 @@ +# `@linkname` and `tsbindgen` v1 + +Design for binding C (and `extern "C"` C++) code from tslang without writing +`.d.ts` declarations by hand. It covers two items: a `@linkname` decorator for +declaring a C function under a different TS name, and `tsbindgen`, a generator +that turns C headers into `.d.ts` declarations. + +Status: approved design, not implemented. Refines Option A of +`docs/c-cpp-header-import.md`; C++ wrapper generation (that doc's "C++ 2" +route) is v2 and gets its own spec once v1 has merged. + +## Why + +The default lib already has a hand-written C layer: +`TypeScriptCompilerDefaultLib/src/wrappers/*.cpp` wrap C++ (`std::regex`, HTTP, +threads) behind `extern "C" TSLANG_EXPORT` functions, and +`src/native/lib.native.d.ts` repeats each signature by hand +(`regexp_match_results_size(cm: Opaque): index`). Every new binding costs two +edits that must agree, and nothing checks that they do. A generator removes +the second edit; `@linkname` lets a binding use a TS name other than the C +symbol. + +## Measured baseline + +Probed with `__build/tslang/windows-msbuild-2026-release` (built 2026-09-25), +`--no-default-lib`, target `x86_64-pc-windows-msvc`. + +| Case | Result | +| --- | --- | +| `@dllname("strlen") declare function cStrLen(s: string): index;` | Works. IR declares and calls `@strlen`; `--emit=jit` and `--emit=exe` both print `5`. | +| `@dllname("?foo@@YAHH@Z") declare function foo(x: i32): i32;` | Works. IR declares `@"?foo@@YAHH@Z"(i32)`. | +| Two declarations with `@dllname("strlen")`, plus a plain `declare function strlen` | **Broken.** IR has `@strlen`, `@strlen.1`, `@strlen.2`. JIT: `Symbols not found: [ strlen.1, strlen.2 ]`. Exe: `LNK2019: unresolved external symbol strlen.1`. | +| `namespace C { @dllname("strlen") export declare function len(...) }` | Links and runs, but IR is `declare dllexport i64 @strlen(ptr)`: the exe re-exports the CRT's `strlen` and the linker writes `p4.lib`/`p4.exp`. | +| `namespace C { export declare function strlen(...) }` | IR is `declare dllexport i64 @C.strlen(ptr)`: the symbol is the namespaced name, and it is marked `dllexport` too. | +| `enum Color {...}` parameter of a `declare function` | Lowers to `i32`. | +| `(a: Opaque, b: Opaque) => i32` parameter | Lowers to `{ ptr, ptr, ptr }` (hybrid function), not a C function pointer. | +| `cmp as Opaque` passed to an `Opaque` parameter | Lowers to bare `ptr @cmp`, a valid C function pointer. | +| `@varargs declare function printf(format: string);` | C varargs (existing test `02funcs_vararg.ts`). `...args: any[]` instead passes an array struct. | + +### Two findings that reduced scope + +**`@linkname` already exists as `@dllname`.** `docs/c-cpp-header-import.md` +(#197) lists a `@linkname("?foo@@YAHH@Z")` attribute as a needed compiler +addition. `@dllname` has since gained exactly that behaviour, for +definitions, declarations, globals, methods and accessors +(`test/tester/tests/export_dllname.ts`). What is missing is a name that fits +static linking, and two defects in the declaration case (below). + +**C callbacks need no compiler work for v1.** A tslang function type is +always a `HybridFunctionType` (`MLIRGenTypes.cpp`, `getFunctionType`), which +is ABI-incompatible with a C function pointer. But casting a plain function to +`Opaque` produces the bare symbol address, so generated bindings type callback +parameters as `Opaque` and callers write `fn as Opaque`. + +## PR 1 — `@linkname` + +### Decorator + +`@linkname("sym")` is a second spelling of `@dllname("sym")`, with identical +meaning everywhere `@dllname` is read today: `MLIRGenFunctions.cpp` +(`mlirGenFunctionLikeDeclaration`), `MLIRGenImpl.h` +(`processFunctionAttributes`), `MLIRGenVariables.cpp`, `MLIRGenClasses.cpp`. +Add `#define LINK_NAME "linkname"` to `Defines.h` and one predicate +`isLinkNameDecorator(name)` (true for `DLL_NAME` or `LINK_NAME`) used at each +of those sites. The attribute stored on the op stays `DLL_NAME`, so lowering, +`ExportFixPass` and `DeclarationPrinter` need no change; `__decls` keeps +printing `@dllname(...)`, which the importer reads identically. + +Both decorators on one declaration with different values is an error: +`conflicting @dllname/@linkname: 'a' vs 'b'`. Same value is accepted. + +`@dllname` is not deprecated. + +### Fix: bindings that share a symbol + +Today the function rename happens at LLVM level, in `ExportFixPass` +(`F.setName(dllName)`), and LLVM silently makes a taken name unique. Move the +function rename to MLIR, into `LowerToLLVM.cpp` next to the existing global +rename (the `renamedGlobals` loop, which already uses +`SymbolTable::replaceAllSymbolUses` + `setSymbolName`). For each +`mlir_ts::FuncOp` with a `DLL_NAME` attribute, remove the attribute, then: + +| Symbol `sym` already in the module? | Action | +| --- | --- | +| No | Rename the op to `sym` and update all uses. | +| Yes, same function type, at least one of the two is a declaration | Point all uses of the renamed op at the existing symbol, keep whichever is a definition, erase the other. | +| Yes, same function type, both are definitions | Error: `'a' and 'b' both define symbol 'sym'`. | +| Yes, different function type | Error: `'a' binds symbol 'sym' with type T1, but 'b' declares it with type T2`. | + +"Same function type" compares the `mlir_ts::FunctionType` of the two ops, +including the varargs flag. + +`ExportFixPass`'s `DLL_NAME` branch stays as a fallback for any function +attribute still set after MLIR lowering; once the MLIR rename runs, it never +fires. + +### Fix: no `dllexport` on a declaration + +In `processFunctionAttributes` (`MLIRGenImpl.h`), `dllExport` is currently +`getExportModifier(...) || InternalFlags::DllExport`. Require a body as well: +a declaration without a body never gets the `export` attribute. `isPublic` +(which keeps the symbol alive through SymbolDCE) is computed separately and +is unchanged. + +The `-shared` cross-module tests import re-parsed declarations that carry +`@dllimport`; after this change those get `import` only instead of both +`export` and `import` (where `import` won anyway, being applied last in +`ExportFixPass`). The full ctest suite verifies this. + +### PR 1 tests + +- `test/tester/tests/declare_linkname.ts`, registered with + `tslang_add_test` as compile and JIT tests: + - `@linkname("strlen") declare function a(...)` and + `@dllname("strlen") declare function b(...)`, plus + `declare function strlen(...)` in the same file, all called and all + returning the right length; + - the same binding inside a namespace with `export declare`; + - `@linkname` on a TS-defined function called through a `declare` of the + target name. +- A negative test for each new error (type mismatch, two definitions, + conflicting decorators) in `test/tester/linkname/`, registered like the + `call-arity` cases in `test/tester/CMakeLists.txt`: `--emit=obj`, + `PASS_REGULAR_EXPRESSION` on the message, `FAIL_REGULAR_EXPRESSION` on + `Stack dump|Assertion failed`. +- A `DeclarationPrinter` unit test: a `@linkname` function prints as + `@dllname("...")`. +- Manual check, recorded in the PR: `--emit=llvm` of the namespace case shows + no `dllexport` on `@strlen`, and `--emit=exe` writes no `.lib`/`.exp`. + +## PR 2 — `tsbindgen` v1 + +### Structure + +- `tslang/lib/TsClangImporter/` — static library, no dependency on MLIRGen: + - `HeaderParser`: runs clang (`clang::tooling`, a `RecursiveASTVisitor` + plus `PPCallbacks` for macros) and returns plain records: functions, + records, enums, typedefs, macros, each with its source location. + - `TypeMapper`: C type → tslang type text, or a skip reason. + - `DtsPrinter`: records → `.d.ts` text. +- `tslang/tsbindgen/` — CLI executable over the library. +- Links against clang libraries from the in-tree LLVM install + (`clangTooling`, `clangFrontend`, `clangAST`, `clangSema`, `clangLex`, + `clangBasic`, …). `find_package(Clang)` is already in + `tslang/CMakeLists.txt`; `tslang.exe` itself gains no new dependency. + +### Command line + +```text +tsbindgen [-I]... [-D[=v]]... [--target ] + [--filter ]... [--namespace [--strip-prefix

]] + [-o ] [-- ...] +``` + +- `--target` defaults to the host triple. Type widths come from clang's + `ASTContext` for that target. +- A `.cpp` input is parsed as C++; only functions with C language linkage are + taken. +- clang's resource directory is located relative to the executable + (`/../lib/clang/`), falling back to the one the build used. + Without it, `stddef.h`/`stdint.h` fail to resolve. + +### What gets emitted + +A declaration is **selected** if its name matches any `--filter` glob, or, +with no `--filter`, if it is declared in the input file itself (not in an +included header). Types used by a selected declaration are emitted too, +wherever they are declared. Real headers pull in thousands of system +declarations, so this rule is essential. + +### Type mapping + +| C | tslang | +| --- | --- | +| integer types | exact width `i8`…`i64` / `u8`…`u64` from the target. Never `int`/`long`: tslang `long` is 64-bit, but C `long` is 32-bit on Windows. | +| `size_t`, `ssize_t`, `ptrdiff_t`, `intptr_t`, `uintptr_t` | `index` | +| `float` / `double` / `_Bool`, `bool` | `f32` / `f64` / `boolean` | +| `char*`, `const char*` | `string` | +| `char**` | `Reference` | +| `T*`, T a scalar or complete struct | `Reference` | +| `void*`; pointer to an incomplete struct or a C++ class | `Opaque`. A named incomplete struct `S` also gets `type S = Opaque;` and parameters use `S`. | +| function pointer | `Opaque`, with a trailing comment giving the C signature (`// (a: i32) => i32`). | +| complete struct of mappable fields | named tuple `type S = [a: T1, b: T2];`, as `tm` in `core.os.d.ts` | +| C `enum` | TS `enum` with the same names and values | +| C++ `enum : u8` (fixed non-`int` underlying type) | `type E = u8;` plus `const E_A = 1;` per enumerator | +| `...` | fixed parameters only, plus `@varargs` | +| `#define N 42` / `1.5` / `"s"` | `const N = 42;` (integer, float and string literal object-like macros only) | +| `typedef` | `type` alias, unless it names a struct already emitted under the same name | + +Parameter names are taken from the header; an unnamed parameter becomes +`p`. A name that is a TS reserved word gets a `_` suffix. + +### Skipped declarations + +Each of these is left out of the `.d.ts`, with +`// skipped: — ` in its place and a warning on stderr: + +- a union, or a struct with a bitfield or an unmappable field; +- `long double`; +- a function that takes or returns a struct by value (its ABI is unverified; + v2's generated wrappers fix this); +- a function-like macro; +- a `static inline` function (no symbol to link against); +- any other type the mapper does not handle. + +A skip never stops generation. The exit code is `0` if the file was written, +`1` for a clang parse error or I/O failure (clang's diagnostics are printed +as-is), `2` for bad arguments. + +### Naming + +C names are kept. `--namespace N` wraps the output in +`namespace N { export declare function ... }` and adds `@linkname("orig")` to +each function (without it, `namespace C { export declare function strlen }` +binds the symbol `C.strlen`, measured). `--strip-prefix P` removes `P` from the TS name of every +declaration that starts with it; the `@linkname` keeps the C name. +`--strip-prefix` without `--namespace` is an argument error. + +### Output header + +```ts +// Generated by tsbindgen (clang ); do not edit. +// tsbindgen sqlite3.h --filter sqlite3_* --target x86_64-pc-windows-msvc +``` + +### PR 2 tests + +1. **Unit tests** in `tslang/unittests/TsClangImporter/` (gtest, like + `unittests/MLIRGen/DeclarationPrinter.cpp`). Each test passes a short + header string to the importer and checks the printed text. Coverage: every + row of the mapping table, every skip reason, filtering (selected vs + pulled-in types, system declarations excluded), `--namespace`, + `--strip-prefix`, reserved-word parameters. Tests parse with + `-ffreestanding` and clang's own `stdint.h`/`stddef.h`, so they need no + system SDK, and one host checks both targets: `long` maps to `i32` for + `x86_64-pc-windows-msvc` and to `i64` for `x86_64-linux-gnu`. +2. **End-to-end test** in `tslang/test/bindgen/`, run by ctest on the Windows + and Linux CI: + - `fixture.h` + `fixture.c`: one of each feature — scalars, a string in and + out, an out-parameter, an opaque handle, a struct passed by pointer, an + enum, a literal macro, a callback, a varargs function. + - At test time: compile `fixture.c` with the in-tree clang to an object and + a shared library, run `tsbindgen fixture.h -o fixture.d.ts`, compile + `bindgen_test.ts` against it, link with `-obj=` and run it; then run the + same program under `--emit=jit` with `-shared-libs=` pointing at the + shared library. Both runs must print the expected output. +3. **Default-lib check** (manual, reported in the PR description): + `tsbindgen src/wrappers/regex.cpp --filter "regexp_*"` and the same for + `http.cpp`, compared with the matching lines of `lib.native.d.ts`. Known, + expected differences: `int` vs `i32` and `double` vs `f64` spelling. Any + other difference is either a generator bug or a hand-written mistake, and + the PR says which. + +## PR 3 — Packaging + +- `create-release.yml`: add `tsbindgen.exe` and + `3rdParty/llvm/x64/release/lib/clang//include` (placed at + `lib/clang//include` in the package) to the Windows `Compress-Archive` + list; the equivalent `cp` lines for each Linux package. +- A smoke step in each packaging job: from the unpacked package, run + `tsbindgen --version` and generate the fixture header from PR 2's test. + +## Open issues + +- **CI LLVM package contents.** The Windows CI LLVM zip must contain the clang + Frontend/Sema/Tooling libraries and `lib/clang//include`. The local + install does; confirm the zip does at the start of PR 2. If it does not, + re-upload it and bump `CACHE_VERSION` (see the earlier `/MT` re-upload). +- **Struct by value.** Skipped in v1. Before v2 generates wrappers for them, + measure whether tslang's lowering of a named tuple parameter already + matches clang's ABI for struct sizes 1/2/4/8/9/16/17 on both targets + (section 5 of `docs/c-cpp-header-import.md`); if it does, v1's skip can be + lifted without wrappers. +- **Callback lifetime.** `fn as Opaque` is only safe for a function with no + captures. Nothing in the type system enforces this; the generated comment + says so. A thin function-pointer type in the compiler would, and is a + separate proposal. +- **Enum width.** The probe used `int`-sized enums only. The unit tests + cover the C++ fixed-type fallback; an `enum` under `-fshort-enums` is not + handled and is out of scope. + +## Out of scope + +- C++ classes, methods, overloads, templates, and wrapper `.cpp` generation + (v2, separate spec). +- `import "x.h"` inside the compiler (Option B of the import doc). +- Converting the default lib to generated bindings. +- Unions, bitfields, function-like macros, `static inline` functions. + +## Decisions taken + +- `@linkname` is an alias of `@dllname`, not a new mechanism. +- Function renames move to MLIR, where a symbol clash can be detected; the + LLVM-level rename is left only as a fallback. +- `tsbindgen` is a separate executable over a reusable `TsClangImporter` + library, shipped next to `tslang.exe`; `tslang.exe` does not link clang's + frontend. +- v1 covers C and `extern "C"` C++ only; C++ proper is v2 on the same core. +- Parsing uses clang's C++ API, not libclang or `-ast-dump=json`: record + layout, macro values and (for v2) mangling and CodeGen are only available + there. +- Generated names are the C names; `--namespace` and `--strip-prefix` are + opt-in. +- Callbacks are `Opaque`; struct-by-value functions are skipped until + measured. From bb5dffdb20662b34c6deab4af0eb8370af1acb45 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Fri, 25 Sep 2026 21:24:01 +0100 Subject: [PATCH 02/11] Spec: measured tuple layout, skipped-struct pointer rule, resource-dir lookup, C string open issue Co-Authored-By: Claude Opus 5.5 --- .../2026-09-25-tsbindgen-linkname-design.md | 47 ++++++++++++++----- 1 file changed, 35 insertions(+), 12 deletions(-) diff --git a/docs/superpowers/specs/2026-09-25-tsbindgen-linkname-design.md b/docs/superpowers/specs/2026-09-25-tsbindgen-linkname-design.md index 5f94bee9b..798426145 100644 --- a/docs/superpowers/specs/2026-09-25-tsbindgen-linkname-design.md +++ b/docs/superpowers/specs/2026-09-25-tsbindgen-linkname-design.md @@ -36,6 +36,7 @@ Probed with `__build/tslang/windows-msbuild-2026-release` (built 2026-09-25), | `(a: Opaque, b: Opaque) => i32` parameter | Lowers to `{ ptr, ptr, ptr }` (hybrid function), not a C function pointer. | | `cmp as Opaque` passed to an `Opaque` parameter | Lowers to bare `ptr @cmp`, a valid C function pointer. | | `@varargs declare function printf(format: string);` | C varargs (existing test `02funcs_vararg.ts`). `...args: any[]` instead passes an array struct. | +| `type S = [a: i8, b: i32, c: i64]`, `f(ReferenceOf(s))` to `declare function f(s: Reference)` | Storage is `alloca { i8, i32, i64 }`, fields in declared order (C's layout). `f` gets the variable's own storage, and a later `s[1]` reads from it, so C writes are visible. | ### Two findings that reduced scope @@ -88,7 +89,10 @@ rename (the `renamedGlobals` loop, which already uses | Yes, different function type | Error: `'a' binds symbol 'sym' with type T1, but 'b' declares it with type T2`. | "Same function type" compares the `mlir_ts::FunctionType` of the two ops, -including the varargs flag. +including the varargs flag. It is deliberately stricter than comparing the +LLVM types (`string` and `Opaque` both lower to `ptr`): at the point of the +rename the call ops are still TS-typed, so redirecting a call to a callee with +a different TS signature would fail the verifier unless casts were inserted. `ExportFixPass`'s `DLL_NAME` branch stays as a fallback for any function attribute still set after MLIR lowering; once the MLIR rename runs, it never @@ -149,16 +153,19 @@ The `-shared` cross-module tests import re-parsed declarations that carry ```text tsbindgen [-I

]... [-D[=v]]... [--target ] [--filter ]... [--namespace [--strip-prefix

]] - [-o ] [-- ...] + [--resource-dir

] [-o ] [-- ...] ``` - `--target` defaults to the host triple. Type widths come from clang's `ASTContext` for that target. - A `.cpp` input is parsed as C++; only functions with C language linkage are taken. -- clang's resource directory is located relative to the executable - (`/../lib/clang/`), falling back to the one the build used. - Without it, `stddef.h`/`stdint.h` fail to resolve. +- clang's resource directory (its `stddef.h`/`stdint.h`; without it they fail + to resolve) is found in this order: `--resource-dir ` if given; + `/lib/clang/` (the release package layout, where `tsbindgen` + sits at the package root next to `tslang`); `/../lib/clang/` + (an LLVM-style install); the directory the build was configured with. None + found is an error naming the paths tried. ### What gets emitted @@ -182,7 +189,7 @@ declarations, so this rule is essential. | function pointer | `Opaque`, with a trailing comment giving the C signature (`// (a: i32) => i32`). | | complete struct of mappable fields | named tuple `type S = [a: T1, b: T2];`, as `tm` in `core.os.d.ts` | | C `enum` | TS `enum` with the same names and values | -| C++ `enum : u8` (fixed non-`int` underlying type) | `type E = u8;` plus `const E_A = 1;` per enumerator | +| C++ `enum : u8` (fixed non-`int` underlying type) | `type E = u8;` plus `const A = 1;` per enumerator, using the enumerator's own name (`A`, as C code spells it for an unscoped enum; `E_A` for an `enum class`, which has no unqualified name) | | `...` | fixed parameters only, plus `@varargs` | | `#define N 42` / `1.5` / `"s"` | `const N = 42;` (integer, float and string literal object-like macros only) | | `typedef` | `type` alias, unless it names a struct already emitted under the same name | @@ -203,6 +210,12 @@ Each of these is left out of the `.d.ts`, with - a `static inline` function (no symbol to link against); - any other type the mapper does not handle. +A skipped struct does not take its users with it. It is still emitted as +`type S = Opaque;`, so a function that takes or returns `S*` is emitted with +an `S` (that is, `Opaque`) parameter, exactly like an incomplete struct. Only +a function that uses a skipped type **by value** is skipped itself. Without +this rule a single union in a header would remove most of its API. + A skip never stops generation. The exit code is `0` if the file was written, `1` for a clang parse error or I/O failure (clang's diagnostics are printed as-is), `2` for bad arguments. @@ -255,17 +268,27 @@ declaration that starts with it; the `@linkname` keeps the C name. - `create-release.yml`: add `tsbindgen.exe` and `3rdParty/llvm/x64/release/lib/clang//include` (placed at - `lib/clang//include` in the package) to the Windows `Compress-Archive` - list; the equivalent `cp` lines for each Linux package. + `lib/clang//include` in the package, next to `tsbindgen` at the + package root — the second lookup location above) to the Windows + `Compress-Archive` list; the equivalent `cp` lines for each Linux package. - A smoke step in each packaging job: from the unpacked package, run `tsbindgen --version` and generate the fixture header from PR 2's test. ## Open issues -- **CI LLVM package contents.** The Windows CI LLVM zip must contain the clang - Frontend/Sema/Tooling libraries and `lib/clang//include`. The local - install does; confirm the zip does at the start of PR 2. If it does not, - re-upload it and bump `CACHE_VERSION` (see the earlier `/MT` re-upload). +- **CI LLVM package contents.** The LLVM packages used by both the Windows + and Linux CI must contain the clang Frontend/Sema/Tooling libraries and + `lib/clang//include`. The local Windows install does; confirm both CI + packages do at the start of PR 2. If one does not, re-upload it and bump + `CACHE_VERSION` (see the earlier `/MT` re-upload). +- **C-owned `char*` returned as `string`.** A tslang string carries an 8-byte + header before its characters (string constants in the IR start with + `\FF` × 8, and call sites pass `ptr + 8`); a `char*` returned by C has + none. The default lib already returns C strings as `string` + (`regexp_match_results_format`) and this works under `-mm=gc`, so v1 maps + them the same way. Under `-mm=rc` a release of such a pointer is undefined; + record it with the rc model's open items, and revisit if rc becomes a + supported target for bindings. - **Struct by value.** Skipped in v1. Before v2 generates wrappers for them, measure whether tslang's lowering of a named tuple parameter already matches clang's ABI for struct sizes 1/2/4/8/9/16/17 on both targets From 695387d8182c623d0f0f817cf254624fd48d8fa6 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Fri, 25 Sep 2026 22:37:53 +0100 Subject: [PATCH 03/11] Implement @linkname as an alias for @dllname, addressing binding issues and enhancing function declaration handling --- docs/superpowers/plans/2026-09-25-linkname.md | 817 ++++++++++++++++++ 1 file changed, 817 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-25-linkname.md diff --git a/docs/superpowers/plans/2026-09-25-linkname.md b/docs/superpowers/plans/2026-09-25-linkname.md new file mode 100644 index 000000000..bb545d226 --- /dev/null +++ b/docs/superpowers/plans/2026-09-25-linkname.md @@ -0,0 +1,817 @@ +# `@linkname` Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add `@linkname("sym")` as a second spelling of `@dllname("sym")`, and fix two defects that break binding C functions under other TS names: several declarations bound to one symbol get renamed `strlen.1`/`strlen.2`, and `export declare function` marks the bound C function `dllexport`. + +**Architecture:** The alias is normalized once, in `iterateDecorators`, so every existing `DLL_NAME` reader accepts both spellings. The function rename moves from LLVM (`ExportFixPass`, where a taken name silently becomes unique) to MLIR (`LowerToLLVM.cpp`, beside the existing global rename), where a clash can be merged or reported. A declaration without a body stops receiving the `export` attribute; whether it is re-printed into `__decls` is unchanged. + +**Tech Stack:** C++17, MLIR (`mlir::SymbolTable`), the tslang `test-runner` + ctest. + +**Spec:** `docs/superpowers/specs/2026-09-25-tsbindgen-linkname-design.md`, section "PR 1 — `@linkname`". This plan covers PR 1 only; PR 2 (`tsbindgen` v1) and PR 3 (packaging) get their own plans. + +## Global Constraints + +- Build tree: `I:/TypeScriptCompiler/__build/tslang/windows-msbuild-2026-release` (multi-config MSBuild). +- Build: `cmake --build I:/TypeScriptCompiler/__build/tslang/windows-msbuild-2026-release --config Release --target tslang test-runner -j 8`. +- `ctest` must be given `-C Release`, run from the build tree; without it every test shows `(Not Run)`. +- The suite runs with `--no-default-lib`; `strlen` resolves from the C runtime in both AOT and JIT. +- The attribute stored on the op stays `DLL_NAME` (`"dllname"`); `__decls` keeps printing `@dllname(...)`. +- `@dllname` is not deprecated. +- "Same function type" compares `mlir_ts::FunctionType`, including the varargs flag — not the LLVM type. +- Do not stage `tslang/include/TypeScript/VSCodeTemplate/Files.h` (a pre-existing local change). +- Every commit message ends with `Co-Authored-By: Claude Opus 5.5 `. Commits are GPG-signed; if signing times out, retry, then hand the commit to the user. Never pass `--no-gpg-sign`. +- Branch: `git checkout --no-track -b linkname-decorator tsbindgen-linkname-spec` (the spec commits ride along in this PR). + +## Review Focus + +- `@linkname("foo")` on a function already named `foo`: must compile and run unchanged, no error, no self-merge. Test: `same()` in Task 2's `declare_linkname_shared_symbol.ts`. +- `@linkname` on a class method, static method, accessor and exported `let` across a `-shared` boundary: the importer must find the renamed symbol exactly as it does for `@dllname`. Test: Task 1's `export_linkname.ts` / `import_linkname.ts` pair. +- A binding whose target name is already a global variable: must be a clear error, not a crash or an erased global. Test: Task 2's `linkname/not-a-function.ts`. +- `export declare function` inside a namespace must still be importable from another module after the `dllexport` fix. Test: Task 3's `export_declare_linkname.ts` / `import_declare_linkname.ts` pair. +- Two declarations of one symbol that differ only in `string` vs `Opaque` (the same LLVM `ptr`): must be an error naming both TS types, since the TS-typed calls cannot be redirected. Test: Task 2's `linkname/type-mismatch.ts`. + +--- + +### Task 1: `@linkname` alias and conflicting-decorator error + +**Files:** +- Modify: `tslang/include/TypeScript/Defines.h:129` (after `#define DLL_NAME "dllname"`) +- Modify: `tslang/lib/TypeScript/MLIRGenImpl.h:11596-11600` (`iterateDecorators`), and add `checkLinkNameDecorators` directly after `iterateDecorators` +- Modify: `tslang/lib/TypeScript/MLIRGenFunctions.cpp:369` (`mlirGenFunctionPrototype`, before `SmallVector attrs;`) +- Modify: `tslang/lib/TypeScript/MLIRGenVariables.cpp` (`mlirGen(VariableDeclarationList ...)`, directly after the `iterateDecorators` block that ends with the `"invariant"` case, ~line 940) +- Create: `tslang/test/tester/tests/declare_linkname.ts` +- Create: `tslang/test/tester/tests/export_linkname.ts`, `tslang/test/tester/tests/import_linkname.ts` +- Create: `tslang/test/tester/linkname/conflicting-decorators.ts`, `tslang/test/tester/linkname/conflicting-decorators-variable.ts` +- Modify: `tslang/test/tester/CMakeLists.txt` (registrations; see steps) + +**Interfaces:** +- Produces: `#define LINK_NAME "linkname"`; `mlir::LogicalResult MLIRGenImpl::checkLinkNameDecorators(mlir::Location location, Node node, const GenContext &genContext)`; the `linkname_cases` list and loop in `test/tester/CMakeLists.txt`, which Task 2 and Task 3 extend. + +- [ ] **Step 1: Create the branch** + +```bash +cd I:/TypeScriptCompiler +git checkout --no-track -b linkname-decorator tsbindgen-linkname-spec +``` + +- [ ] **Step 2: Write the failing tests** + +`tslang/test/tester/tests/declare_linkname.ts`: + +```ts +// @linkname is @dllname: the TS name is cStrLen, the symbol it binds is the C runtime's strlen +@linkname("strlen") +declare function cStrLen(s: string): index; + +function main() { + assert(cStrLen("hello") == 5); + print("done."); +} +``` + +`tslang/test/tester/tests/export_linkname.ts`: + +```ts +namespace L { + + // @linkname is @dllname under the name that fits a static link; it must rename every kind + // of symbol @dllname does, and survive the __decls round trip to the importer. + + @linkname("linkname_add") + export function add(a: number, b: number): number { + return a + b; + } + + export function addTwice(a: number, b: number): number { + return add(a, b) + add(a, b); + } + + @linkname("linkname_counter") + export let counter = 40; + + export function bump(): number { + counter = counter + 2; + return counter; + } + + export class Calc { + constructor(public factor: number) { + } + + @linkname("linkname_calc_twice") + static twice(x: number): number { + return x * 2; + } + + @linkname("linkname_calc_scale") + scale(x: number): number { + return x * this.factor; + } + + @linkname("linkname_calc_get_double") + get double(): number { + return this.factor * 2; + } + + @linkname("linkname_calc_set_double") + set double(value: number) { + this.factor = value / 2; + } + } +} +``` + +`tslang/test/tester/tests/import_linkname.ts`: + +```ts +import './export_linkname' + +function main() { + assert(L.add(2, 3) == 5); + assert(L.addTwice(2, 3) == 10); + + assert(L.counter == 40); + assert(L.bump() == 42); + + assert(L.Calc.twice(4) == 8); + + const calc = new L.Calc(3); + assert(calc.scale(5) == 15); + + assert(calc.double == 6); + calc.double = 10; + assert(calc.factor == 5); + + print("done."); +} +``` + +`tslang/test/tester/linkname/conflicting-decorators.ts`: + +```ts +@dllname("strlen") +@linkname("wcslen") +declare function f(s: string): index; + +function main() { + print(f("a")); +} +``` + +`tslang/test/tester/linkname/conflicting-decorators-variable.ts`: + +```ts +@dllname("linkname_var_a") +@linkname("linkname_var_b") +export let x = 1; + +function main() { + print(x); +} +``` + +- [ ] **Step 3: Register the tests** + +In `tslang/test/tester/CMakeLists.txt`, directly after the line +`tslang_add_test(NAME test-compile-shared-export-import-dllname ...)` (~line 1283), add: + +```cmake +tslang_add_test(NAME test-compile-shared-export-import-linkname COMMAND test-runner -shared "${PROJECT_SOURCE_DIR}/test/tester/tests/import_linkname.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_linkname.ts") +tslang_add_test(NAME test-compile-declare-linkname COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/declare_linkname.ts") +tslang_add_test(NAME test-jit-declare-linkname COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/declare_linkname.ts") +``` + +Directly after the `foreach(call_arity_case ...)` ... `endforeach()` block (~line 2245), add: + +```cmake +# @dllname and @linkname are one decorator under two names, and a symbol is bound once: each +# case is a program the compiler must reject with the given message instead of binding +# something else or making the name unique. +set(linkname_cases + "conflicting-decorators|conflicting @dllname/@linkname: 'strlen' vs 'wcslen'" + "conflicting-decorators-variable|conflicting @dllname/@linkname: 'linkname_var_a' vs 'linkname_var_b'") +foreach(linkname_case ${linkname_cases}) + string(FIND "${linkname_case}" "|" linkname_sep) + string(SUBSTRING "${linkname_case}" 0 ${linkname_sep} linkname_name) + math(EXPR linkname_sep "${linkname_sep} + 1") + string(SUBSTRING "${linkname_case}" ${linkname_sep} -1 linkname_message) + add_test(NAME test-compile-linkname-${linkname_name} + COMMAND $ --emit=obj --no-default-lib -mm=none + "${PROJECT_SOURCE_DIR}/test/tester/linkname/${linkname_name}.ts" + -o "${CMAKE_CURRENT_BINARY_DIR}/linkname-${linkname_name}.obj") + # PASS_REGULAR_EXPRESSION alone ignores the exit code, so a crash after printing the + # expected message would still pass; FAIL_REGULAR_EXPRESSION catches that. + set_tests_properties(test-compile-linkname-${linkname_name} + PROPERTIES PASS_REGULAR_EXPRESSION "${linkname_message}" + FAIL_REGULAR_EXPRESSION "Stack dump|Assertion failed") +endforeach() +``` + +- [ ] **Step 4: Build and run the new tests to see them fail** + +```bash +cmake --build I:/TypeScriptCompiler/__build/tslang/windows-msbuild-2026-release --config Release --target tslang test-runner -j 8 +cd I:/TypeScriptCompiler/__build/tslang/windows-msbuild-2026-release && ctest -C Release -R "linkname" --output-on-failure +``` + +Expected: all five fail. `test-jit-declare-linkname` fails with `Symbols not found: [ cStrLen ]` (the unknown decorator is ignored today). The shared pair fails to resolve an `L.*` symbol. Both `test-compile-linkname-*` tests fail because no message is printed. + +- [ ] **Step 5: Add `LINK_NAME`** + +In `tslang/include/TypeScript/Defines.h`, after line 129 (`#define DLL_NAME "dllname"`): + +```cpp +#define LINK_NAME "linkname" +``` + +- [ ] **Step 6: Normalize the alias in `iterateDecorators`** + +In `tslang/lib/TypeScript/MLIRGenImpl.h`, replace the tail of `iterateDecorators`: + +```cpp + if (expr == SyntaxKind::Identifier) + { + auto name = MLIRHelper::getName(expr.as(), stringAllocator); + functor(name, args); + } +``` + +with: + +```cpp + if (expr == SyntaxKind::Identifier) + { + auto name = MLIRHelper::getName(expr.as(), stringAllocator); + // @linkname is @dllname under the name that fits a static link; every reader + // of DLL_NAME takes both + if (name == LINK_NAME) + { + name = DLL_NAME; + } + + functor(name, args); + } +``` + +- [ ] **Step 7: Add `checkLinkNameDecorators`** + +In `tslang/lib/TypeScript/MLIRGenImpl.h`, directly after the closing brace of `iterateDecorators`: + +```cpp + // Two @dllname/@linkname decorators naming different symbols leave it ambiguous which one + // the declaration binds, so that is an error rather than last-one-wins. An error only + // reaches the user from a statement that fails (outputDiagnostics), so callers must + // return this result. + mlir::LogicalResult checkLinkNameDecorators(mlir::Location location, Node node, const GenContext &genContext) + { + StringRef first; + StringRef second; + iterateDecorators(node, genContext, [&](StringRef name, SmallVector args) { + if (name != DLL_NAME || args.empty()) + { + return; + } + + if (first.empty()) + { + first = args.front(); + } + else if (second.empty() && args.front() != first) + { + second = args.front(); + } + }); + + if (!second.empty()) + { + emitError(location) << "conflicting @dllname/@linkname: '" << first << "' vs '" << second << "'"; + return mlir::failure(); + } + + return mlir::success(); + } +``` + +- [ ] **Step 8: Call it for function-like declarations** + +In `tslang/lib/TypeScript/MLIRGenFunctions.cpp`, `mlirGenFunctionPrototype`, directly before `SmallVector attrs;` (line 369): + +```cpp + if (mlir::failed(checkLinkNameDecorators(location, functionLikeDeclarationBaseAST, genContext))) + { + return std::make_tuple(funcOp, funcProto, mlir::failure(), false); + } + +``` + +- [ ] **Step 9: Call it for variable declarations** + +In `tslang/lib/TypeScript/MLIRGenVariables.cpp`, `mlirGen(VariableDeclarationList ...)`, directly after the `iterateDecorators(variableDeclarationListAST->parent, ...)` block closes and before the closing brace of `if (variableDeclarationListAST->parent)`: + +```cpp + if (mlir::failed(checkLinkNameDecorators(loc(variableDeclarationListAST), variableDeclarationListAST->parent, genContext))) + { + return mlir::failure(); + } +``` + +- [ ] **Step 10: Build and run the new tests** + +```bash +cmake --build I:/TypeScriptCompiler/__build/tslang/windows-msbuild-2026-release --config Release --target tslang test-runner -j 8 +cd I:/TypeScriptCompiler/__build/tslang/windows-msbuild-2026-release && ctest -C Release -R "linkname|dllname" --output-on-failure +``` + +Expected: all pass, including the existing `test-compile-shared-export-import-dllname`. + +- [ ] **Step 11: Commit** + +```bash +cd I:/TypeScriptCompiler +git add tslang/include/TypeScript/Defines.h tslang/lib/TypeScript/MLIRGenImpl.h tslang/lib/TypeScript/MLIRGenFunctions.cpp tslang/lib/TypeScript/MLIRGenVariables.cpp tslang/test/tester/CMakeLists.txt tslang/test/tester/tests/declare_linkname.ts tslang/test/tester/tests/export_linkname.ts tslang/test/tester/tests/import_linkname.ts tslang/test/tester/linkname +git commit -m "Accept @linkname as a second name for @dllname + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 2: Rename functions in MLIR, merging bindings of one symbol + +**Files:** +- Modify: `tslang/lib/TypeScript/LowerToLLVM.cpp:7508-7530` (`TypeScriptToLLVMLoweringPass::runOnOperation`, add a function loop directly after the `renamedGlobals` loop) +- Modify: `tslang/lib/TypeScriptExportPass/ExportFixPass.cpp:29-35` (comment only) +- Create: `tslang/test/tester/tests/declare_linkname_shared_symbol.ts` +- Create: `tslang/test/tester/linkname/type-mismatch.ts`, `tslang/test/tester/linkname/two-definitions.ts`, `tslang/test/tester/linkname/not-a-function.ts` +- Modify: `tslang/test/tester/CMakeLists.txt` (registrations; `linkname_cases` list from Task 1) + +**Interfaces:** +- Consumes: `LINK_NAME` alias (Task 1); the `linkname_cases` list in `test/tester/CMakeLists.txt` (Task 1). +- Produces: after `LowerToLLVM`, no `mlir_ts::FuncOp` carries `DLL_NAME`; the error texts `binds symbol '' with type`, `both define symbol ''`, `binds symbol '', which is not a function`. + +- [ ] **Step 1: Write the failing tests** + +`tslang/test/tester/tests/declare_linkname_shared_symbol.ts`: + +```ts +// Several declarations may bind one C symbol. Each used to reach LLVM as a function of its own, +// and LLVM's setName made the taken name unique ("strlen.1"), so the call bound nothing. + +declare function strlen(s: string): index; + +@linkname("strlen") +declare function cStrLen(s: string): index; + +@dllname("strlen") +declare function cStrLen2(s: string): index; + +// both spellings naming the same symbol is not a conflict +@dllname("strlen") +@linkname("strlen") +declare function cStrLen3(s: string): index; + +namespace C { + @linkname("strlen") + export declare function len(s: string): index; +} + +// a declaration bound to a function defined in this module +@linkname("linkname_twice") +function twice(x: i32): i32 { + return x * 2; +} + +declare function linkname_twice(x: i32): i32; + +// naming the symbol a function already has changes nothing +@linkname("same") +function same(): i32 { + return 7; +} + +function main() { + assert(strlen("a") == 1); + assert(cStrLen("ab") == 2); + assert(cStrLen2("abc") == 3); + assert(cStrLen3("abcd") == 4); + assert(C.len("abcde") == 5); + assert(twice(3) == 6); + assert(linkname_twice(4) == 8); + assert(same() == 7); + print("done."); +} +``` + +`tslang/test/tester/linkname/type-mismatch.ts`: + +```ts +// string and Opaque both lower to ptr, but the calls are still TS-typed when the rename runs +@linkname("strlen") +declare function a(s: string): index; + +@linkname("strlen") +declare function b(s: Opaque): index; + +function main() { + let o: Opaque; + print(a("x"), b(o)); +} +``` + +`tslang/test/tester/linkname/two-definitions.ts`: + +```ts +@linkname("linkname_dup") +function a(): i32 { + return 1; +} + +@linkname("linkname_dup") +function b(): i32 { + return 2; +} + +function main() { + print(a(), b()); +} +``` + +`tslang/test/tester/linkname/not-a-function.ts`: + +```ts +let linkname_counter = 1; + +@linkname("linkname_counter") +declare function f(): i32; + +function main() { + print(linkname_counter, f()); +} +``` + +Before relying on `not-a-function.ts`, confirm the global's symbol name: + +```bash +I:/TypeScriptCompiler/__build/tslang/windows-msbuild-2026-release/bin/tslang.exe --emit=mlir --no-default-lib -mm=none I:/TypeScriptCompiler/tslang/test/tester/linkname/not-a-function.ts 2>&1 | grep -n "ts.Global" +``` + +Expected: a `ts.Global` with `sym_name = "linkname_counter"`. If the name differs, change the `@linkname` argument and the expected message in Step 2 to that exact name. + +- [ ] **Step 2: Register the tests** + +In `tslang/test/tester/CMakeLists.txt`, after the three lines added in Task 1 Step 3: + +```cmake +tslang_add_test(NAME test-compile-declare-linkname-shared-symbol COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/declare_linkname_shared_symbol.ts") +tslang_add_test(NAME test-jit-declare-linkname-shared-symbol COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/declare_linkname_shared_symbol.ts") +``` + +Extend the `linkname_cases` list from Task 1 to: + +```cmake +set(linkname_cases + "conflicting-decorators|conflicting @dllname/@linkname: 'strlen' vs 'wcslen'" + "conflicting-decorators-variable|conflicting @dllname/@linkname: 'linkname_var_a' vs 'linkname_var_b'" + "type-mismatch|binds symbol 'strlen' with type" + "two-definitions|both define symbol 'linkname_dup'" + "not-a-function|binds symbol 'linkname_counter', which is not a function") +``` + +- [ ] **Step 3: Build and run the new tests to see them fail** + +```bash +cmake --build I:/TypeScriptCompiler/__build/tslang/windows-msbuild-2026-release --config Release --target tslang test-runner -j 8 +cd I:/TypeScriptCompiler/__build/tslang/windows-msbuild-2026-release && ctest -C Release -R "linkname" --output-on-failure +``` + +Expected: `test-jit-declare-linkname-shared-symbol` fails with `Symbols not found: [ strlen.1, ... ]`; `test-compile-declare-linkname-shared-symbol` fails with `LNK2019: unresolved external symbol strlen.1`; the three new `test-compile-linkname-*` cases fail (no message). The Task 1 tests still pass. + +- [ ] **Step 4: Implement the MLIR rename** + +In `tslang/lib/TypeScript/LowerToLLVM.cpp`, `TypeScriptToLLVMLoweringPass::runOnOperation`, directly after the closing brace of `for (auto globalOp : renamedGlobals) { ... }`: + +```cpp + // @dllname/@linkname on a function: renamed here rather than in ExportFixPass, where LLVM's + // setName silently makes a taken name unique ("strlen.1") and the call binds nothing. + // Several TS declarations may bind one C symbol; the ones with the same TS type collapse into + // one op. The type is compared as the TS type, not the LLVM one: string and Opaque both lower + // to ptr, but the calls are still TS-typed here, and redirecting one to a callee of another + // signature would fail the verifier. + SmallVector renamedFuncs; + m.walk([&](mlir_ts::FuncOp funcOp) { + auto dllName = funcOp->getAttrOfType(DLL_NAME); + if (!dllName) + { + return; + } + + // naming the symbol the function already has is a no-op; dropped up front so the loop + // below never meets a still-attributed op under the target name + if (dllName == funcOp.getSymNameAttr()) + { + funcOp->removeAttr(DLL_NAME); + return; + } + + renamedFuncs.push_back(funcOp); + }); + + for (auto funcOp : renamedFuncs) + { + auto dllName = funcOp->getAttrOfType(DLL_NAME); + funcOp->removeAttr(DLL_NAME); + + auto existingSymbol = SymbolTable::lookupSymbolIn(m, dllName); + if (existingSymbol) + { + auto existingFunc = dyn_cast(existingSymbol); + if (!existingFunc) + { + funcOp.emitError("'") << funcOp.getSymName() << "' binds symbol '" << dllName.getValue() + << "', which is not a function"; + signalPassFailure(); + return; + } + + // an op still carrying the attribute is itself about to move away from this name + if (existingFunc->hasAttr(DLL_NAME)) + { + funcOp.emitError("'") << funcOp.getSymName() << "' binds symbol '" << dllName.getValue() + << "', which @dllname/@linkname renames to '" + << existingFunc->getAttrOfType(DLL_NAME).getValue() << "'"; + signalPassFailure(); + return; + } + + if (existingFunc.getFunctionType() != funcOp.getFunctionType()) + { + funcOp.emitError("'") << funcOp.getSymName() << "' binds symbol '" << dllName.getValue() + << "' with type " << funcOp.getFunctionType() << ", but '" + << existingFunc.getSymName() << "' declares it with type " + << existingFunc.getFunctionType(); + signalPassFailure(); + return; + } + + if (!funcOp.isExternal() && !existingFunc.isExternal()) + { + funcOp.emitError("'") << funcOp.getSymName() << "' and '" << existingFunc.getSymName() + << "' both define symbol '" << dllName.getValue() << "'"; + signalPassFailure(); + return; + } + + if (failed(SymbolTable::replaceAllSymbolUses(funcOp, dllName, m))) + { + funcOp.emitError("can't rename function to @dllname '") << dllName.getValue() << "'"; + signalPassFailure(); + return; + } + + // keep whichever of the two has the body + if (funcOp.isExternal()) + { + funcOp.erase(); + } + else + { + existingFunc.erase(); + SymbolTable::setSymbolName(funcOp, dllName); + } + + continue; + } + + if (failed(SymbolTable::replaceAllSymbolUses(funcOp, dllName, m))) + { + funcOp.emitError("can't rename function to @dllname '") << dllName.getValue() << "'"; + signalPassFailure(); + return; + } + + SymbolTable::setSymbolName(funcOp, dllName); + } +``` + +If `funcOp.getSymName()` / `getSymNameAttr()` do not compile, the generated accessors for `SymbolNameAttr:$sym_name` in `TypeScriptOps.td` are the ones to use — check `grep -n "getSymName" I:/TypeScriptCompiler/__build/tslang/windows-msbuild-2026-release/include/TypeScript/TypeScriptOps.h.inc`. + +- [ ] **Step 5: Mark the `ExportFixPass` branch as a fallback** + +In `tslang/lib/TypeScriptExportPass/ExportFixPass.cpp`, directly above `if (F.hasFnAttribute(DLL_NAME))`: + +```cpp + // TypeScriptToLLVMLoweringPass renames @dllname/@linkname functions in MLIR, where a + // taken name can be merged or reported; this only catches an attribute added after it +``` + +- [ ] **Step 6: Build and run the linkname and dllname tests** + +```bash +cmake --build I:/TypeScriptCompiler/__build/tslang/windows-msbuild-2026-release --config Release --target tslang test-runner -j 8 +cd I:/TypeScriptCompiler/__build/tslang/windows-msbuild-2026-release && ctest -C Release -R "linkname|dllname" --output-on-failure +``` + +Expected: all pass. If a `test-compile-linkname-*` case fails because the message is missing although compilation failed, run its command by hand; an error from `LowerToLLVM` is printed the same way as the existing `can't rename global to @dllname` error, so compare with that. + +- [ ] **Step 7: Commit** + +```bash +cd I:/TypeScriptCompiler +git add tslang/lib/TypeScript/LowerToLLVM.cpp tslang/lib/TypeScriptExportPass/ExportFixPass.cpp tslang/test/tester/CMakeLists.txt tslang/test/tester/tests/declare_linkname_shared_symbol.ts tslang/test/tester/linkname +git commit -m "Rename @dllname functions in MLIR, merging bindings of one symbol + +LLVM's setName made a taken name unique, so a second declaration of +strlen called strlen.1 and linked to nothing. + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 3: No `dllexport` on a declaration + +**Files:** +- Modify: `tslang/lib/TypeScript/MLIRGenImpl.h:2667-2673` (`processFunctionAttributes`) +- Create: `tslang/test/tester/linkname/export-declare.ts` +- Create: `tslang/test/tester/tests/export_declare_linkname.ts`, `tslang/test/tester/tests/import_declare_linkname.ts` +- Modify: `tslang/test/tester/CMakeLists.txt` + +**Interfaces:** +- Consumes: `@linkname` (Task 1); the MLIR rename (Task 2). +- Produces: `processFunctionAttributes` keeps returning `dllExport` exactly as before (it decides `__decls` re-export in `mlirGenFunctionPrototype`); only the `export` attribute is withheld from a body-less declaration. + +- [ ] **Step 1: Write the tests** + +`tslang/test/tester/linkname/export-declare.ts`: + +```ts +namespace C { + @linkname("strlen") + export declare function len(s: string): index; +} + +function main() { + print(C.len("hello")); +} +``` + +`tslang/test/tester/tests/export_declare_linkname.ts`: + +```ts +namespace C { + + // `export declare` makes the binding visible to importers; it must not make this module + // re-export the C function it binds. + + @linkname("strlen") + export declare function len(s: string): index; +} +``` + +`tslang/test/tester/tests/import_declare_linkname.ts`: + +```ts +import './export_declare_linkname' + +function main() { + assert(C.len("hello") == 5); + print("done."); +} +``` + +- [ ] **Step 2: Register the tests** + +In `tslang/test/tester/CMakeLists.txt`, after the two lines added in Task 2 Step 2: + +```cmake +tslang_add_test(NAME test-compile-shared-export-import-declare-linkname COMMAND test-runner -shared "${PROJECT_SOURCE_DIR}/test/tester/tests/import_declare_linkname.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_declare_linkname.ts") +``` + +Directly after the `linkname_cases` `endforeach()`: + +```cmake +# `export declare function` makes a declaration visible to importers; it must not mark the C +# function it binds dllexport, which made an exe re-export the CRT's strlen. ExportFixPass +# adds dllexport only for MSVC targets, so off Windows this passes either way. +add_test(NAME test-compile-linkname-declare-not-exported + COMMAND $ --emit=llvm --no-default-lib -mm=none + "${PROJECT_SOURCE_DIR}/test/tester/linkname/export-declare.ts" -o -) +set_tests_properties(test-compile-linkname-declare-not-exported + PROPERTIES PASS_REGULAR_EXPRESSION "declare i64 @strlen\\(ptr\\)" + FAIL_REGULAR_EXPRESSION "dllexport|Stack dump|Assertion failed") +``` + +- [ ] **Step 3: Build and run them before the fix** + +```bash +cmake --build I:/TypeScriptCompiler/__build/tslang/windows-msbuild-2026-release --config Release --target tslang test-runner -j 8 +cd I:/TypeScriptCompiler/__build/tslang/windows-msbuild-2026-release && ctest -C Release -R "declare-not-exported|export-import-declare-linkname" --output-on-failure +``` + +Expected: `test-compile-linkname-declare-not-exported` FAILS (the IR has `declare dllexport i64 @strlen(ptr)`). Record whether `test-compile-shared-export-import-declare-linkname` passes: it is the regression guard for Step 5, so it must pass here. If it fails here, the cross-module import of an ambient declaration is broken already on this branch — stop and report the output rather than continuing. + +- [ ] **Step 4: Withhold `export` from a body-less declaration** + +In `tslang/lib/TypeScript/MLIRGenImpl.h`, `processFunctionAttributes`, replace: + +```cpp + if (dllExport) + { + attrs.push_back({mlir::StringAttr::get(builder.getContext(), "export"), mlir::UnitAttr::get(builder.getContext())}); + } +``` + +with: + +```cpp + // a declaration has no body to export: `export declare function` only makes it visible + // to importers, which the return value decides (__decls), while the attribute made the + // exe re-export the C function the declaration binds + if (dllExport && functionLikeDeclarationBaseAST->body) + { + attrs.push_back({mlir::StringAttr::get(builder.getContext(), "export"), mlir::UnitAttr::get(builder.getContext())}); + } +``` + +Leave `return dllExport;` unchanged. + +- [ ] **Step 5: Build and run the tests again** + +```bash +cmake --build I:/TypeScriptCompiler/__build/tslang/windows-msbuild-2026-release --config Release --target tslang test-runner -j 8 +cd I:/TypeScriptCompiler/__build/tslang/windows-msbuild-2026-release && ctest -C Release -R "linkname|dllname|shared" --output-on-failure +``` + +Expected: all pass. If `test-compile-shared-export-import-declare-linkname` now fails with an unresolved `__imp_strlen`, the importer's `@dllimport` re-print of the ambient declaration depended on the exporting module re-exporting `strlen`. Stop and report it; do not work around it in this task. + +- [ ] **Step 6: Check the exe no longer writes an import library** + +```bash +cd "$(mktemp -d)" +I:/TypeScriptCompiler/__build/tslang/windows-msbuild-2026-release/bin/tslang.exe --emit=exe --no-default-lib I:/TypeScriptCompiler/tslang/test/tester/linkname/export-declare.ts -o export-declare.exe +./export-declare.exe +ls +``` + +Expected: prints `5`; the directory holds `export-declare.exe` and no `export-declare.lib` / `export-declare.exp`. Note the result for the PR description. + +- [ ] **Step 7: Commit** + +```bash +cd I:/TypeScriptCompiler +git add tslang/lib/TypeScript/MLIRGenImpl.h tslang/test/tester/CMakeLists.txt tslang/test/tester/linkname/export-declare.ts tslang/test/tester/tests/export_declare_linkname.ts tslang/test/tester/tests/import_declare_linkname.ts +git commit -m "Keep dllexport off a declaration that has no body + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 4: Full suite and the header-import doc + +**Files:** +- Modify: `docs/c-cpp-header-import.md:203` (the "C++ 1" row) + +**Interfaces:** +- Consumes: Tasks 1-3. + +- [ ] **Step 1: Point the import doc at the decorator that now exists** + +In `docs/c-cpp-header-import.md`, in the "C++ 1" table row, replace: + +```text +(needs a small compiler addition: a `@linkname("?foo@@YAHH@Z")` attribute on declarations so the emitted `FuncOp` symbol differs from the TS-visible name) +``` + +with: + +```text +(`@linkname("?foo@@YAHH@Z")`, an alias of `@dllname`, makes the emitted `FuncOp` symbol differ from the TS-visible name) +``` + +- [ ] **Step 2: Run the whole suite** + +```bash +cd I:/TypeScriptCompiler/__build/tslang/windows-msbuild-2026-release && ctest -j 16 -C Release --output-on-failure +``` + +Expected: `100% tests passed, 0 tests failed`. The count is the previous total (2884 on 2026-09-24) plus the new tests. A `for-await` async test or `00try_finally_return` failing once is a known flake: rerun that test alone before investigating. + +- [ ] **Step 3: Commit** + +```bash +cd I:/TypeScriptCompiler +git add docs/c-cpp-header-import.md +git commit -m "Note @linkname in the header-import design + +Co-Authored-By: Claude Opus 5.5 " +``` + +- [ ] **Step 4: Finish the branch** + +Use superpowers:finishing-a-development-branch. The PR description lists the three changes, the before/after of the `strlen.1` reproduction, the Task 3 Step 6 result, and ends with: + +```text +🤖 Generated with [Claude Code](https://claude.com/claude-code) +``` From 0dbb4724636078acc0c7a923887de420ac2aeebd Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Fri, 25 Sep 2026 22:38:01 +0100 Subject: [PATCH 04/11] Update CMake minimum version to 3.21 and enable TSLANG language in project declaration --- tslang/include/TypeScript/VSCodeTemplate/Files.h | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/tslang/include/TypeScript/VSCodeTemplate/Files.h b/tslang/include/TypeScript/VSCodeTemplate/Files.h index 46a3df18d..6a1122741 100644 --- a/tslang/include/TypeScript/VSCodeTemplate/Files.h +++ b/tslang/include/TypeScript/VSCodeTemplate/Files.h @@ -291,15 +291,12 @@ const auto LAUNCH_JSON_DATA_LINUX = R"raw( #define CMAKE_FOLDER_PATH "cmake" -const auto CMAKE_LISTS_TXT_DATA = R"raw(cmake_minimum_required(VERSION 3.20) +const auto CMAKE_LISTS_TXT_DATA = R"raw(cmake_minimum_required(VERSION 3.21) # Make CMake find ts-language modules list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/cmake") -project(<> CXX) - -# Enable TS-language -enable_language(TSLANG) +project(<> CXX TSLANG) # Include folders include_directories(${CMAKE_TSLANG_DIR}/defaultlib) @@ -331,10 +328,7 @@ endif() # program is compiled for one model and linked against another model's default lib. set(CMAKE_TSLANG_FLAGS "${CMAKE_TSLANG_FLAGS} -mm=${TSLANG_MEMORY_MODEL}") # global -if(WIN32) -else() - set(CMAKE_TSLANG_FLAGS "${CMAKE_TSLANG_FLAGS} -relocation-model=pic") # global -endif() +set(CMAKE_POSITION_INDEPENDENT_CODE ON) # .ts files compile with TSLANG command; .cpp with the C++ compiler. add_executable(${PROJECT_NAME} @@ -363,7 +357,7 @@ const auto CMAKE_PRESETS_JSON_DATA = R"raw({ "version": 3, "cmakeMinimumRequired": { "major": 3, - "minor": 20, + "minor": 21, "patch": 0 }, "configurePresets": [ From 601ea688ae9c708546cc9a5561888ea57b858043 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Sat, 26 Sep 2026 12:24:54 +0100 Subject: [PATCH 05/11] JIT: run main with parameters and a result; optimize JIT runs by default --emit=jit refused any main with parameters or a result ("expected 'main' to have 0 inputs and 0 results"), because the JIT called the entry as void(*)(). It now calls a generated `i32 __tslang_jit_main(i32, ptr)` thunk, built in LLVM IR after the optimizer, that adapts to main's lowered signature: argc as an integer or number, argv as a string[] (data, length) or a Ref (the C char ** itself), and the result (void, integer or number) as the process exit code. - LowerToAffineLoops: the JIT gate is now a shape check for main(argc?: i32 | number, argv?: string[] | Ref): void | i32 | number - tslang: trailing positionals are program arguments for --emit=jit (argv[0] is the input file, '--' before dash-prefixed ones); an error in every other mode, as a second positional was before - tslang: --emit=jit turns --opt on (level 3 by default) unless --opt is given explicitly, --opt_level=0, or --di (--opt strips the debug info) - tests: 00main_argc_argv (JIT and compiled), 00main_number_result (JIT only: a compiled number-typed main gets an int argc and returns no exit code) Co-Authored-By: Claude Opus 5.5 --- tslang/lib/TypeScript/LowerToAffineLoops.cpp | 29 +++- tslang/test/tester/CMakeLists.txt | 5 + tslang/test/tester/tests/00main_argc_argv.ts | 12 ++ .../test/tester/tests/00main_number_result.ts | 6 + tslang/tslang/jit.cpp | 132 +++++++++++++++++- tslang/tslang/tslang.cpp | 20 ++- 6 files changed, 195 insertions(+), 9 deletions(-) create mode 100644 tslang/test/tester/tests/00main_argc_argv.ts create mode 100644 tslang/test/tester/tests/00main_number_result.ts diff --git a/tslang/lib/TypeScript/LowerToAffineLoops.cpp b/tslang/lib/TypeScript/LowerToAffineLoops.cpp index a2aa3b5e8..8177da8ef 100644 --- a/tslang/lib/TypeScript/LowerToAffineLoops.cpp +++ b/tslang/lib/TypeScript/LowerToAffineLoops.cpp @@ -2707,14 +2707,33 @@ void TypeScriptToAffineLoweringTSFuncPass::runOnFunction() LLVM_DEBUG(llvm::dbgs() << "\n!! BEFORE FUNC DUMP: \n" << function << "\n";); // We only lower the main function as we expect that all other functions have been inlined. + // The JIT calls `main` through a thunk with the C entry point's signature (see jit.cpp), which + // can only adapt the shapes a C `main` has: `argc` as an integer or a number, `argv` as a + // `string[]` or as the C `char **` itself, a `Ref`, and an exit code that is an integer + // or a number. if (tsContext.compileOptions.isJit && function.getName() == MAIN_ENTRY_NAME) { - auto voidType = mlir_ts::VoidType::get(function.getContext()); - // Verify that the given main has no inputs and results. - if (function.getNumArguments() || - llvm::any_of(function.getFunctionType().getResults(), [&](mlir::Type type) { return type != voidType; })) + auto isArgc = [](mlir::Type type) { return isa(type) || isa(type); }; + auto isArgv = [](mlir::Type type) { + if (auto refType = dyn_cast(type)) + { + return isa(refType.getElementType()); + } + + auto arrayType = dyn_cast(type); + return arrayType && isa(arrayType.getElementType()); + }; + auto isExitCode = [&](mlir::Type type) { return isa(type) || isArgc(type); }; + + auto inputs = function.getFunctionType().getInputs(); + auto results = function.getFunctionType().getResults(); + auto validInputs = inputs.size() <= 2 + && (inputs.size() < 1 || isArgc(inputs[0])) + && (inputs.size() < 2 || isArgv(inputs[1])); + auto validResults = results.size() <= 1 && llvm::all_of(results, isExitCode); + if (!validInputs || !validResults) { - function.emitError("expected 'main' to have 0 inputs and 0 results"); + function.emitError("expected 'main' to be 'main(argc?: i32 | number, argv?: string[] | Ref): void | i32 | number'"); return signalPassFailure(); } } diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 64f51d5db..0d0ae6a4a 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -322,6 +322,7 @@ tslang_add_test(NAME test-compile-00-vars COMMAND test-runner "${PROJECT_SOURCE_ tslang_add_test(NAME test-compile-00-var-bindings COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00var_bindings.ts") tslang_add_test(NAME test-compile-00-globals COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00globals.ts") tslang_add_test(NAME test-compile-00-globals-entry-point COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00globals_entry_point.ts") +tslang_add_test(NAME test-compile-00-main-argc-argv COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00main_argc_argv.ts") tslang_add_test(NAME test-compile-00-globals2 COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00globals2.ts") tslang_add_test(NAME test-compile-00-globals3 COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00globals3.ts") tslang_add_test(NAME test-compile-00-arrays COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/00array.ts") @@ -758,6 +759,10 @@ tslang_add_test(NAME test-jit-00-vars COMMAND test-runner -jit "${PROJECT_SOURCE tslang_add_test(NAME test-jit-00-var-bindings COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00var_bindings.ts") tslang_add_test(NAME test-jit-00-globals COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00globals.ts") tslang_add_test(NAME test-jit-00-globals-entry-point COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00globals_entry_point.ts") +tslang_add_test(NAME test-jit-00-main-argc-argv COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00main_argc_argv.ts") +# JIT only: a compiled `main(argc: number): number` is called by the C runtime with an int argc +# where it reads a double, and its double result is not an exit code +tslang_add_test(NAME test-jit-00-main-number-result COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00main_number_result.ts") tslang_add_test(NAME test-jit-00-globals2 COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00globals2.ts") tslang_add_test(NAME test-jit-00-globals3 COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00globals3.ts") tslang_add_test(NAME test-jit-00-arrays COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/00array.ts") diff --git a/tslang/test/tester/tests/00main_argc_argv.ts b/tslang/test/tester/tests/00main_argc_argv.ts new file mode 100644 index 000000000..689067265 --- /dev/null +++ b/tslang/test/tester/tests/00main_argc_argv.ts @@ -0,0 +1,12 @@ +// The C entry point's shape: `argv` as a `Ref` is C's `char **` itself (a `string[]` is a +// (data, length) pair, which a C runtime does not pass). The JIT used to refuse any `main` with +// parameters or a result; it now calls it through a thunk that passes the program arguments and +// takes the result as the exit code. The exit code is 0 here, because the runner requires it. +// argv[0] is the program - the input file under the JIT, the executable when compiled - so only +// its presence is checked. +function main(argc: int, args: Ref): int { + assert(argc >= 1, "argc"); + assert(Deref(args[0]).length > 0, "argv[0]"); + print("done."); + return 0; +} diff --git a/tslang/test/tester/tests/00main_number_result.ts b/tslang/test/tester/tests/00main_number_result.ts new file mode 100644 index 000000000..c001ed7dd --- /dev/null +++ b/tslang/test/tester/tests/00main_number_result.ts @@ -0,0 +1,6 @@ +// `argc` and the exit code as numbers, which lower to double: the JIT's entry thunk converts both. +function main(argc: number): number { + assert(argc >= 1, "argc"); + print("done."); + return 0; +} diff --git a/tslang/tslang/jit.cpp b/tslang/tslang/jit.cpp index 247eb232c..2a132e1a6 100644 --- a/tslang/tslang/jit.cpp +++ b/tslang/tslang/jit.cpp @@ -11,6 +11,7 @@ #include "llvm/ExecutionEngine/Orc/LLJIT.h" #include "llvm/ExecutionEngine/Orc/RTDyldObjectLinkingLayer.h" #include "llvm/ExecutionEngine/SectionMemoryManager.h" +#include "llvm/IR/IRBuilder.h" #include "llvm/IR/LLVMContext.h" #include "llvm/IR/Module.h" #include "llvm/Support/DynamicLibrary.h" @@ -56,6 +57,7 @@ extern cl::opt dumpObjectFile; extern cl::opt objectFilename; extern cl::opt mainFuncName; extern cl::opt inputFilename; +extern cl::list programArgs; // obj extern cl::opt TargetTriple; @@ -354,6 +356,105 @@ extern "C" EXCEPTION_DISPOSITION __CxxFrameHandler3(struct _EXCEPTION_RECORD *, struct _DISPATCHER_CONTEXT *); #endif +#define JIT_ENTRY_THUNK_NAME "__tslang_jit_main" +using JitEntryThunkFn = int (*)(int, char **); + +// The entry point keeps whatever signature its TS declaration lowered to - `void @main()`, +// `double @main()`, `i32 @main(i32, { ptr, i64 })`, ... - and calling it through a C++ function +// pointer of the wrong type reads garbage arguments. So give the JIT one signature to call, the C +// entry point's, and let this thunk adapt it in IR, where the entry's own types are known: `argc` +// converted to the first parameter's type, `argv` and `argc` as the `string[]` (data, length) of the +// second or `argv` alone for a `Ref`, and the result converted to the exit code. LowerToAffineLoops has already refused any +// other shape of `main`, with a source location; this still checks, for an entry picked with `-e`. +static llvm::Error addEntryThunk(llvm::Module &llvmModule, llvm::StringRef entryName) +{ + auto *entry = llvmModule.getFunction(entryName); + if (!entry) + { + return llvm::createStringError("entry point '" + entryName + "' not found"); + } + + auto unsupported = [&]() { + return llvm::createStringError("entry point '" + entryName + + "' must be '(argc?: i32 | number, argv?: string[] | Ref) => void | i32 | number'"); + }; + + auto &context = llvmModule.getContext(); + auto *i32Type = llvm::Type::getInt32Ty(context); + auto *thunk = llvm::Function::Create( + llvm::FunctionType::get(i32Type, {i32Type, llvm::PointerType::getUnqual(context)}, false), + llvm::Function::ExternalLinkage, JIT_ENTRY_THUNK_NAME, llvmModule); + // the entry may throw through it; Win64 can only unwind a frame it has unwind info for + thunk->setUWTableKind(llvm::UWTableKind::Async); + + llvm::IRBuilder<> builder(llvm::BasicBlock::Create(context, "entry", thunk)); + auto *argc = thunk->getArg(0); + auto *argv = thunk->getArg(1); + + auto *entryType = entry->getFunctionType(); + if (entryType->getNumParams() > 2) + { + thunk->eraseFromParent(); + return unsupported(); + } + + llvm::SmallVector args; + for (auto [index, paramType] : llvm::enumerate(entryType->params())) + { + auto *arrayType = llvm::dyn_cast(paramType); + if (index == 0 && paramType->isIntegerTy()) + { + args.push_back(builder.CreateSExtOrTrunc(argc, paramType)); + } + else if (index == 0 && paramType->isFloatingPointTy()) + { + args.push_back(builder.CreateSIToFP(argc, paramType)); + } + else if (index == 1 && paramType->isPointerTy()) + { + // `Ref`: the C `char **` as it is + args.push_back(argv); + } + else if (index == 1 && arrayType && arrayType->getNumElements() == 2 && + arrayType->getElementType(0)->isPointerTy() && arrayType->getElementType(1)->isIntegerTy()) + { + llvm::Value *array = llvm::UndefValue::get(arrayType); + array = builder.CreateInsertValue(array, argv, 0); + array = builder.CreateInsertValue(array, builder.CreateSExtOrTrunc(argc, arrayType->getElementType(1)), 1); + args.push_back(array); + } + else + { + thunk->eraseFromParent(); + return unsupported(); + } + } + + auto *call = builder.CreateCall(entryType, entry, args); + call->setCallingConv(entry->getCallingConv()); + + auto *resultType = entryType->getReturnType(); + if (resultType->isVoidTy()) + { + builder.CreateRet(builder.getInt32(0)); + } + else if (resultType->isIntegerTy()) + { + builder.CreateRet(builder.CreateSExtOrTrunc(call, i32Type)); + } + else if (resultType->isFloatingPointTy()) + { + builder.CreateRet(builder.CreateFPToSI(call, i32Type)); + } + else + { + thunk->eraseFromParent(); + return unsupported(); + } + + return llvm::Error::success(); +} + int runJit(int argc, char **argv, mlir::ModuleOp module, CompileOptions &compileOptions) { // tslang.exe is an x64 process: it cannot execute i386 or wasm code in-process. Refused up @@ -653,6 +754,14 @@ int runJit(int argc, char **argv, mlir::ModuleOp module, CompileOptions &compile return -1; } + // after the optimizer, so it cannot inline an entry with debug info into a thunk without any + if (auto err = addEntryThunk(*llvmModule, mainFuncName.getValue())) + { + llvm::WithColor::error(llvm::errs(), "tslang") << err << "\n"; + llvm::consumeError(std::move(err)); + return -1; + } + auto maybeJit = llvm::orc::LLJITBuilder() .setJITTargetMachineBuilder(std::move(*tmBuilderOrError)) @@ -806,11 +915,28 @@ int runJit(int argc, char **argv, mlir::ModuleOp module, CompileOptions &compile return -1; } - if (invoke(mainFuncName.getValue()) != 0) + auto entryThunk = jit->lookup(JIT_ENTRY_THUNK_NAME); + if (!entryThunk) { + auto err = entryThunk.takeError(); + llvm::WithColor::error(llvm::errs(), "tslang") << "JIT invocation failed, error: " << err << "\n"; + llvm::consumeError(std::move(err)); return -1; } + // as a C runtime passes them: the program (here the input file) first, and a null after the last + llvm::SmallVector programArgv; + programArgv.push_back(const_cast(inputFilename.getValue().c_str())); + for (auto &programArg : programArgs) + { + programArgv.push_back(const_cast(programArg.c_str())); + } + + auto programArgc = static_cast(programArgv.size()); + programArgv.push_back(nullptr); + + auto exitCode = entryThunk->toPtr()(programArgc, programArgv.data()); + // The JIT program has finished. On Windows/COFF the MLIR/ORC LLJIT platform // registers process-level atexit glue that faults during teardown (exception // 0x80000003), so the normal CRT exit path crashes after a successful run. @@ -840,7 +966,7 @@ int runJit(int argc, char **argv, mlir::ModuleOp module, CompileOptions &compile // Must stay unconditional: without it release builds crash (0xC0000005) or // deadlock in teardown after async tests — the AsyncRuntime static destructor // waits on thread-pool workers during DLL_PROCESS_DETACH (see above). - TerminateProcess(GetCurrentProcess(), 0); + TerminateProcess(GetCurrentProcess(), exitCode); #endif - return 0; + return exitCode; } diff --git a/tslang/tslang/tslang.cpp b/tslang/tslang/tslang.cpp index 3f28e56c0..08f71b115 100644 --- a/tslang/tslang/tslang.cpp +++ b/tslang/tslang/tslang.cpp @@ -74,6 +74,8 @@ cl::OptionCategory TypeScriptCompilerDebugCategory("JIT Debug Options"); cl::OptionCategory TypeScriptCompilerBuildCategory("Executable/Shared library Build Options(used in -emit=exe and -emit=dll)"); cl::opt inputFilename(cl::Positional, cl::desc(""), cl::init("-"), cl::value_desc("filename"), cl::cat(TypeScriptCompilerCategory)); +// must stay declared after inputFilename: positional options match in declaration order, and an unbounded list has to be last +cl::list programArgs(cl::Positional, cl::desc("... (--emit=jit: passed to 'main' after the input file, which is argv[0]; put them after '--' if they start with '-')"), cl::ZeroOrMore, cl::cat(TypeScriptCompilerCategory)); cl::opt outputFilename("o", cl::desc("Output filename"), cl::value_desc("filename"), cl::cat(TypeScriptCompilerCategory)); cl::opt emitAction("emit", cl::desc("Select the kind of output desired"), @@ -99,7 +101,7 @@ cl::opt emitAction("emit", cl::desc("Select the kind of output desi cl::init(RunJIT), cl::cat(TypeScriptCompilerCategory)); -cl::opt enableOpt{"opt", cl::desc("Enable optimizations"), cl::init(false), cl::cat(TypeScriptCompilerCategory), cl::cat(TypeScriptCompilerCategory)}; +cl::opt enableOpt{"opt", cl::desc("Enable optimizations (on by default with --emit=jit, unless --opt_level=0 or --di; --opt=false turns it off)"), cl::init(false), cl::cat(TypeScriptCompilerCategory), cl::cat(TypeScriptCompilerCategory)}; cl::opt optLevel{"opt_level", cl::desc("Optimization level"), cl::ZeroOrMore, cl::value_desc("0-3"), cl::init(3), cl::cat(TypeScriptCompilerCategory)}; cl::opt sizeLevel{"size_level", cl::desc("Optimization size level"), cl::ZeroOrMore, cl::value_desc("value"), cl::init(0), cl::cat(TypeScriptCompilerCategory)}; @@ -372,6 +374,22 @@ int main(int argc, char **argv) cl::ParseCommandLineOptions(argc, argv, "TypeScript native compiler\n"); + // Before program arguments existed a second positional was an error; keep it one wherever + // nothing would receive it + if (!programArgs.empty() && (emitAction != Action::RunJIT || newVSCodeFolder || newCMakeFolder || installDefaultLibCmd)) + { + llvm::WithColor::error(llvm::errs(), "tslang") << "program arguments are only used with --emit=jit, got '" << programArgs.front() << "'\n"; + return -1; + } + + // A JIT run is running the program, not building it, so it optimizes (at --opt_level, 3 unless + // given) without being asked. Not when asked otherwise: an explicit --opt either way wins, and + // --opt_level=0 or --di mean an unoptimized run - --opt would strip the debug info. + if (emitAction == Action::RunJIT && !enableOpt.getNumOccurrences() && optLevel != 0 && !generateDebugInfo) + { + enableOpt.setValue(true); + } + if (newVSCodeFolder.getValue()) { return createVSCodeFolder(argc, argv); From e6047cb81ed09f1f760122e405c904abd5b0941b Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Sat, 26 Sep 2026 13:19:17 +0100 Subject: [PATCH 06/11] Plan: tsbindgen v1 (PR 2), code verified against LLVM 22.1.8 Co-Authored-By: Claude Opus 5.5 --- .../superpowers/plans/2026-09-26-tsbindgen.md | 3701 +++++++++++++++++ 1 file changed, 3701 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-26-tsbindgen.md diff --git a/docs/superpowers/plans/2026-09-26-tsbindgen.md b/docs/superpowers/plans/2026-09-26-tsbindgen.md new file mode 100644 index 000000000..6ee90bf2c --- /dev/null +++ b/docs/superpowers/plans/2026-09-26-tsbindgen.md @@ -0,0 +1,3701 @@ +# `tsbindgen` v1 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** A `tsbindgen` executable that turns a C header (or the `extern "C"` part of a C++ file) into a tslang source file of bindings (`declare function`, named-tuple `type`s, `enum`s, `const`s) that a tslang program includes to call the C library. + +**Architecture:** A static library, `TsClangImporter`, runs clang over the input (`clang::tooling::ToolInvocation`, a `RecursiveASTVisitor`, and `PPCallbacks` for macros), maps each C declaration to a tslang one (`TypeMapper`, widths from the target's `ASTContext`), selects what to emit (the input file's own declarations, or `--filter` globs, plus every type those use), and prints the bindings (`BindingPrinter`). `tsbindgen` is a thin command line over it. `tslang.exe` gains no dependency. + +**Tech Stack:** C++17, clang 22.1.8 C++ API (Tooling, Frontend, AST, Lex, Basic) from the in-tree LLVM install, gtest/gmock via `add_mlir_unittest`, ctest + `cmake -P` scripts. + +**Spec:** `docs/superpowers/specs/2026-09-25-tsbindgen-linkname-design.md`, section "PR 2 — `tsbindgen` v1", as corrected by "Deviations from the spec" below. PR 3 (packaging) is not in this plan. + +**Status of the code below:** every file in this plan was built against LLVM 22.1.8 and run on Windows x64 on 2026-09-26: 46 unit tests pass, `test-bindgen-cli` and `test-bindgen-plain` pass under ctest. `test-bindgen-namespace` needs PR 1 and was not run. Nothing was run on Linux. + +## Prerequisite: PR 1 (`@linkname`) must be merged first + +PR 1 is **not implemented**. Commit `695387d8` ("Implement @linkname as an alias for @dllname…") adds only its plan, `docs/superpowers/plans/2026-09-25-linkname.md`; no code in `tslang/include` or `tslang/lib` mentions `linkname`, and a probe of `namespace Fx { @linkname("fx_add") export declare function add(...) }` still binds the symbol `Fx.add` and marks it `dllexport` (link error: unresolved `Fx.add`). + +`--namespace` output depends on it: every function it emits carries `@linkname("")`. So: + +1. Execute `docs/superpowers/plans/2026-09-25-linkname.md` and merge it. +2. Then branch this work from `main`: `git fetch origin && git checkout --no-track -b tsbindgen-v1 origin/main && git push -u origin tsbindgen-v1`. + +Tasks 1–4 and the `plain` half of Task 5 do not need PR 1; `test-bindgen-namespace` (Task 5) does, and fails with `unresolved external symbol Fx.add` without it. + +## Deviations from the spec (measured 2026-09-26) + +Each of these was found by running generated bindings through tslang; the spec's version does not work. + +| Spec says | Plan does | Why (measured) | +| --- | --- | --- | +| Output is a `.d.ts` (`-o `) | Output is a `.ts` file, included with `/// `; `-o` defaults to stdout; a `-o x.d.ts` gets a warning | In a `.d.ts` — and in any file pulled in with `import` — a `const` is an ambient declaration with no value: `const FIXTURE_ANSWER = 42;` fails with `LNK2019: unresolved external symbol FIXTURE_ANSWER` (exe) and `Symbols not found: [ FIXTURE_ANSWER ]` (JIT). The same text in a `.ts` included by `/// ` works in both. `import "./fixture"` is also wrong for another reason: when `fixture.dll` exists it loads that as a tslang library (`MLIRGenModule.cpp`, `mlirGen(ImportDeclaration)`). | +| Signed C integers map to `i8`…`i64` | Signed → `s8`…`s64`, unsigned → `u8`…`u64` | tslang's `iN` are **signless** (`MLIRGenTypes.cpp`: `{"i32", builder.getIntegerType(32)}` vs `{"s32", builder.getIntegerType(32, true)}`): an `int8_t` -5 prints `251`, an `int16_t` -3 prints `65533`, an `int32_t` -5 prints `4294967291`. With `sN` all print correctly. | +| `struct Node { struct Node *next; }` → `next: Reference` | A pointer field that leads back to its own struct — directly, through another struct, or through a typedef — is `Opaque`, with `/* Reference: a type cannot refer to itself */` | tslang dies on a self-referencing type alias: `type Node = [value: i32, next: Reference]; function main() { let n: Node = [1, null]; }` exits 127 with no message, even at `--emit=mlir`. That is a compiler bug to file separately; tsbindgen must not emit a cycle meanwhile. | +| Function-pointer parameters get a trailing `// (a: i32) => i32` | Inline `fn: Opaque /* (p0: s32) => s32 */` for parameters and fields; trailing `//` for a `typedef` | A declaration can have several callbacks; one trailing comment cannot say which is which. Parameter names inside the signature are `p0…`: a C function type carries no names. The file header says once that a callback must have no captures and is passed as `fn as Opaque` (spec open issue "Callback lifetime"). | +| `runToolOnCodeWithArgs`-style parsing (implied) | `ToolInvocation` with our own diagnostic consumer | `runToolOnCodeWithArgs` ignores errors in the clang command line itself: `tsbindgen fixture.h -- --bogus-flag` printed `error: unknown argument` and still exited 0 with output. | +| (silent) | An object-like macro that is not a literal (`#define FLAGS (1 << 3)`, `#define API __declspec(dllexport)`) is skipped with `macro is not a literal`; one with no tokens (include guards) is ignored | The skip list names function-like macros only; a silent drop would hide API. | +| (silent) | `--strip-prefix` never produces a name that is empty, starts with a digit, or is taken: it keeps the C name and warns; a stripped reserved word gets `_` | `fx_len` stripping to `len` when `len` exists would declare two functions `len`. | +| (silent) | A struct whose clang layout is not natural field-after-field (packed, `#pragma pack`, over-aligned) is skipped: `packed or over-aligned layout` | A named tuple is laid out naturally; the spec's measured baseline covers only natural layouts. | +| (silent) | Nothing selected is a warning | `tsbindgen umbrella.h` where `umbrella.h` only `#include`s others emits an empty file; the warning says to use `--filter`. | + +Task 8 amends the spec with this table. + +## Global Constraints + +- Build tree: `I:/TypeScriptCompiler/__build/tslang/windows-msbuild-2026-release` (multi-config MSBuild). After any `CMakeLists.txt` change, reconfigure: `cmake .` in the build tree. +- Build: `cmake --build I:/TypeScriptCompiler/__build/tslang/windows-msbuild-2026-release --config Release -j 16 --target `. +- `ctest` must be given `-C Release` and run from the build tree; without it every test shows `(Not Run)`. +- A header change followed by a crash in code that did not change (`SEH exception 0xC0000005`, segfault in `tsbindgen`) is a stale object: rebuild with `--clean-first` before debugging. +- clang is **22.1.8**; its resource directory is `/lib/clang/22`. Use `${LLVM_LIBRARY_DIR}/clang/${LLVM_VERSION_MAJOR}` in CMake and `CLANG_VERSION_MAJOR` / `CLANG_VERSION_STRING` (`clang/Basic/Version.h`) in C++; never hard-code 22. +- clang 22 has no `ElaboratedType`, and `TagType::getDecl()` returns the declaration as written: look through sugar with `getAs<...>()`, and take a struct's definition with `getDefinition()`. +- The unit tests build only where `3rdParty/llvm-project/third-party/unittest` exists (the same condition as `MLIRGenTests`). CI coverage comes from the ctest scripts in Task 4 and Task 5, not the unit tests. +- `tslang.exe` must not link any new library. Only `TsClangImporter`, `tsbindgen` and `TsClangImporterTests` link clang's frontend. +- Integer mapping: signed → `s8`/`s16`/`s32`/`s64`, unsigned → `u8`/`u16`/`u32`/`u64`, never `i*`, `int` or `long`. +- The generated file is `.ts`, included with `/// `. Never `import` it. +- Exit codes: `0` file written, `1` parse error or I/O failure (clang's diagnostics printed as clang prints them), `2` bad arguments. +- Every commit message ends with `Co-Authored-By: Claude Opus 5.5 `. Commits are GPG-signed; if signing times out, retry, then hand the commit to the user. Never pass `--no-gpg-sign`. +- Run `gh auth switch --user ASDAlexander77` before any `gh` command. + +## Review Focus + +1. **Narrow integer and `bool` parameters on Linux x86-64.** clang-compiled callees on the SysV ABI assume the caller sign/zero-extended an `int8_t`, `uint16_t` or `bool` argument to 32 bits; tslang emits `declare` parameters without `signext`/`zeroext`. Expected: `narrow -5 65535 -5 false true` on Linux exactly as on Windows. Pinned by the `narrow` line of `test/bindgen/expected.txt` (Task 5), checked on Linux CI in Task 6. If it fails there, tsbindgen's mapping is right and tslang's call lowering is wrong: file a compiler issue, do not change the mapping. +2. **Hosted system headers from inside ctest.** `fixture.h` includes ``, ``, `` without `-ffreestanding`, so clang's driver must find the MSVC/Windows SDK headers (or glibc's) with no developer prompt in the environment. Expected: exit 0 on both CI systems. Pinned by `test-bindgen-plain` (Task 5). +3. **A real header, C++ input.** `TypeScriptCompilerDefaultLib/src/wrappers/regex.cpp` is a `.cpp` with an `extern "C"` API over `std::regex`, pulling in the C++ standard library. Expected: exit 0, every `regexp_*` function emitted, matching `lib.native.d.ts` up to type spelling. Checked by hand in Task 7, with the differences written into the PR description. +4. **The same stem as a library.** `import "./fixture"` loads a `fixture.dll`/`libfixture.so` beside the file as a tslang library; the generated file's third line says to include it with `/// `. Pinned by `test-bindgen-cli` (the hint) and `test-bindgen-plain` (a C library named `fixture_c`, bindings named `fixture.ts`). +5. **Per-target layout.** A struct's field widths come from the target: `struct Sized { long a; char b; }` is `[a: s32, b: s8]` for `x86_64-pc-windows-msvc` and `[a: s64, b: s8]` for `x86_64-linux-gnu`. Pinned by `TypeMapping.StructFieldsHaveTheTargetsWidths` (Task 2). + +## File map + +| File | Responsibility | +| --- | --- | +| `tslang/include/TsClangImporter/BindingModel.h`, `tslang/lib/TsClangImporter/BindingModel.cpp` | The mapped declarations (`Decl`, `TsType`, `HeaderModel`) and small helpers: `renderType`, `collectKeys`, `nameFromKey`, `escapeReserved` | +| `tslang/include/TsClangImporter/TypeMapper.h`, `tslang/lib/TsClangImporter/TypeMapper.cpp` | C type → `TsType`, a struct's skip reason (union, bitfield, layout, …), type-cycle breaking | +| `tslang/include/TsClangImporter/HeaderParser.h`, `tslang/lib/TsClangImporter/HeaderParser.cpp` | Runs clang, visits declarations and macros, builds the `HeaderModel` | +| `tslang/include/TsClangImporter/BindingPrinter.h`, `tslang/lib/TsClangImporter/BindingPrinter.cpp` | Selection (`--filter` / main file + used types), TS names (`--strip-prefix`), printing (`--namespace`) | +| `tslang/lib/TsClangImporter/CMakeLists.txt` | The static library, linking clang | +| `tslang/tsbindgen/tsbindgen.cpp`, `tslang/tsbindgen/CMakeLists.txt` | Command line, resource-directory lookup, exit codes, version | +| `tslang/unittests/TsClangImporter/*` | gtest unit tests over `parseHeader` + `printBindings` | +| `tslang/test/bindgen/*` | Fixture C library, programs using the generated bindings, the expected output, and the two `cmake -P` drivers | +| Modified: `tslang/lib/CMakeLists.txt`, `tslang/CMakeLists.txt`, `tslang/unittests/CMakeLists.txt`, `tslang/test/tester/CMakeLists.txt` | `add_subdirectory` lines and three ctest registrations | + +--- + +### Task 1: Confirm clang in the CI packages; the library skeleton and its unit-test target + +**Files:** +- Create: `tslang/include/TsClangImporter/BindingModel.h` +- Create: `tslang/lib/TsClangImporter/BindingModel.cpp` +- Create: `tslang/lib/TsClangImporter/CMakeLists.txt` +- Create: `tslang/unittests/TsClangImporter/CMakeLists.txt` +- Create: `tslang/unittests/TsClangImporter/BindingModelTest.cpp` +- Modify: `tslang/lib/CMakeLists.txt` (after `add_subdirectory(TypeScriptMemAllocPass)`) +- Modify: `tslang/unittests/CMakeLists.txt` (after `add_subdirectory(MLIRGen)`) + +**Interfaces:** +- Produces: namespace `tsbindgen`; `struct TsType { Kind kind; std::string name; std::vector pointee; std::string comment; bool functionPointer; std::string skipReason; bool skipped() const; static TsType builtin(std::string, std::string = {}); static TsType named(std::string key); static TsType reference(TsType); static TsType skip(std::string); }`; `std::string renderType(const TsType &, const std::function &nameOf)`; `void collectKeys(const TsType &, std::vector &)`; `std::string nameFromKey(const std::string &)`; `std::string escapeReserved(std::string)`; `struct Field { std::string name; TsType type; }`; `struct Enumerator { std::string name; std::string value; }`; `enum class DeclKind { Macro, OpaqueStruct, Struct, Enum, FixedEnum, Typedef, Function }`; `struct Decl { DeclKind kind; std::string key, name; bool inMainFile, inSystemHeader; std::string skipReason; std::vector fields; TsType type; bool varargs; std::vector enumerators; bool scoped; std::string value; }`; `struct HeaderModel { std::vector decls; }`. Keys are `"struct:"`, `"enum:"`, `"typedef:"`, `"fn:"`, `"macro:"`. + +- [ ] **Step 1: Confirm both CI LLVM packages contain clang's frontend libraries, the `clang` binary and the resource headers** + +The spec's open issue "CI LLVM package contents". The URLs are the `LLVM_ZIPFILE` / `LLVM_TARGZFILE` values in `.github/workflows/cmake-test-release-win.yml` and `cmake-test-release-linux.yml`. + +```bash +cd "$(mktemp -d)" +curl -sL "$(grep -oP 'LLVM_TARGZFILE: "\K[^"]+' /i/TypeScriptCompiler/.github/workflows/cmake-test-release-linux.yml)" -o llvm.tgz +tar -tzf llvm.tgz | grep -E 'lib/libclang(Tooling|Frontend|AST|Lex|Basic)\.a$|bin/clang$|lib/clang/[0-9]+/include/stddef\.h$' +curl -sL "$(grep -oP 'LLVM_ZIPFILE: "\K[^"]+' /i/TypeScriptCompiler/.github/workflows/cmake-test-release-win.yml)" -o llvm.zip +unzip -l llvm.zip | grep -E 'lib/clang(Tooling|Frontend|AST|Lex|Basic)\.lib$|bin/clang\.exe$|lib/clang/[0-9]+/include/stddef\.h$' +``` + +Expected: each command lists 7 lines (5 libraries, the binary, `stddef.h`). If a package lacks any of them, stop: it has to be rebuilt with clang and re-uploaded, and `CACHE_VERSION` bumped in both workflows (as for the earlier `/MT` re-upload) — that is a separate change the user makes before this plan continues. + +- [ ] **Step 2: Write the failing test** + +`tslang/unittests/TsClangImporter/BindingModelTest.cpp`: + +```cpp +// The model's helpers: how a mapped type is spelled, which declarations it refers to, and the +// names that must not reach TS unchanged. + +#include "TsClangImporter/BindingModel.h" + +#include "gmock/gmock.h" + +using namespace tsbindgen; + +TEST(BindingModel, RendersBuiltinNamedAndReference) +{ + auto nameOf = [](const std::string &key) { return "TS_" + nameFromKey(key); }; + + EXPECT_EQ(renderType(TsType::builtin("s32"), nameOf), "s32"); + EXPECT_EQ(renderType(TsType::named("struct:Point"), nameOf), "TS_Point"); + EXPECT_EQ(renderType(TsType::reference(TsType::reference(TsType::named("struct:Point"))), nameOf), + "Reference>"); +} + +TEST(BindingModel, CollectsEachKeyOnce) +{ + std::vector keys; + collectKeys(TsType::reference(TsType::named("struct:Point")), keys); + collectKeys(TsType::named("struct:Point"), keys); + collectKeys(TsType::named("enum:Color"), keys); + collectKeys(TsType::builtin("string"), keys); + + EXPECT_THAT(keys, testing::ElementsAre("struct:Point", "enum:Color")); +} + +TEST(BindingModel, KeysNameTheirDeclaration) +{ + EXPECT_EQ(nameFromKey("typedef:callback_t"), "callback_t"); + EXPECT_EQ(nameFromKey("plain"), "plain"); +} + +TEST(BindingModel, ReservedWordsGetASuffix) +{ + EXPECT_EQ(escapeReserved("delete"), "delete_"); + EXPECT_EQ(escapeReserved("function"), "function_"); + EXPECT_EQ(escapeReserved("yield"), "yield_"); + EXPECT_EQ(escapeReserved("value"), "value"); + EXPECT_EQ(escapeReserved("len"), "len"); +} + +TEST(BindingModel, SkippedTypeKeepsItsReason) +{ + auto type = TsType::skip("long double"); + EXPECT_TRUE(type.skipped()); + EXPECT_EQ(type.skipReason, "long double"); + EXPECT_FALSE(TsType::builtin("f64").skipped()); +} +``` + +`tslang/unittests/TsClangImporter/CMakeLists.txt` (Task 2 and Task 3 add files to this list): + +```cmake +add_mlir_unittest(TsClangImporterTests + BindingModelTest.cpp +) + +target_link_libraries(TsClangImporterTests + PRIVATE + TsClangImporter +) +``` + +In `tslang/unittests/CMakeLists.txt`, after `add_subdirectory(MLIRGen)`: + +```cmake +add_subdirectory(TsClangImporter) +``` + +- [ ] **Step 3: Run it to verify it fails** + +Run: `cd I:/TypeScriptCompiler/__build/tslang/windows-msbuild-2026-release && cmake . && cmake --build . --config Release -j 16 --target TsClangImporterTests` +Expected: FAIL — CMake error that `TsClangImporter` is not a target (or, once it is, `fatal error C1083: Cannot open include file: 'TsClangImporter/BindingModel.h'`). + +- [ ] **Step 4: Write the model** + +`tslang/include/TsClangImporter/BindingModel.h`: + +```cpp +#ifndef TSCLANGIMPORTER_BINDINGMODEL_H +#define TSCLANGIMPORTER_BINDINGMODEL_H + +#include +#include +#include + +namespace tsbindgen +{ + +// A tslang type as the printer spells it. A named type holds the key of the declaration it refers +// to, not a TS name: --strip-prefix renames declarations after the header has been mapped. +struct TsType +{ + enum class Kind + { + Builtin, + Named, + Reference + }; + + Kind kind = Kind::Builtin; + std::string name; // Builtin: the tslang spelling; Named: the key of a Decl + std::vector pointee; // Reference: exactly one element + std::string comment; // shown next to the type: a function pointer's C signature, ... + bool functionPointer = false; + std::string skipReason; // non-empty: the C type has no tslang mapping + + bool skipped() const + { + return !skipReason.empty(); + } + + static TsType builtin(std::string name, std::string comment = {}); + static TsType named(std::string key); + static TsType reference(TsType pointee); + static TsType skip(std::string reason); +}; + +// Spells `type`, asking `nameOf` for the TS name of each named declaration it refers to. +std::string renderType(const TsType &type, const std::function &nameOf); + +// The declaration keys `type` refers to, in order, each once. +void collectKeys(const TsType &type, std::vector &keys); + +// "struct:Point" -> "Point" +std::string nameFromKey(const std::string &key); + +// A name that is a TS reserved word gets a `_` suffix: `delete` -> `delete_`. +std::string escapeReserved(std::string name); + +struct Field +{ + std::string name; + TsType type; +}; + +struct Enumerator +{ + std::string name; + std::string value; // decimal +}; + +enum class DeclKind +{ + Macro, // const N = 42; + OpaqueStruct, // type S = Opaque; + Struct, // type S = [a: T1, b: T2]; (type S = Opaque; when skipped) + Enum, // enum E { A = 0 } + FixedEnum, // type E = u8; const A = 1; + Typedef, // type T = ...; + Function // declare function f(...): R; +}; + +struct Decl +{ + DeclKind kind = DeclKind::Function; + std::string key; // unique within the model: ":" + std::string name; // the C name + bool inMainFile = false; + bool inSystemHeader = false; + std::string skipReason; // non-empty: left out, with a `// skipped:` line in its place + + std::vector fields; // Struct: fields; Function: parameters + TsType type; // Function: result; Typedef: aliased type; FixedEnum: underlying + bool varargs = false; // Function + std::vector enumerators; // Enum, FixedEnum + bool scoped = false; // FixedEnum from an `enum class` + std::string value; // Macro: a TS literal +}; + +struct HeaderModel +{ + std::vector decls; // in the order the header declares them +}; + +} // namespace tsbindgen + +#endif // TSCLANGIMPORTER_BINDINGMODEL_H +``` + +`tslang/lib/TsClangImporter/BindingModel.cpp`: + +```cpp +#include "TsClangImporter/BindingModel.h" + +#include + +namespace tsbindgen +{ + +TsType TsType::builtin(std::string name, std::string comment) +{ + TsType type; + type.kind = Kind::Builtin; + type.name = std::move(name); + type.comment = std::move(comment); + return type; +} + +TsType TsType::named(std::string key) +{ + TsType type; + type.kind = Kind::Named; + type.name = std::move(key); + return type; +} + +TsType TsType::reference(TsType pointee) +{ + TsType type; + type.kind = Kind::Reference; + type.pointee.push_back(std::move(pointee)); + return type; +} + +TsType TsType::skip(std::string reason) +{ + TsType type; + type.skipReason = std::move(reason); + return type; +} + +std::string renderType(const TsType &type, const std::function &nameOf) +{ + switch (type.kind) + { + case TsType::Kind::Builtin: + return type.name; + case TsType::Kind::Named: + return nameOf(type.name); + case TsType::Kind::Reference: + return "Reference<" + renderType(type.pointee.front(), nameOf) + ">"; + } + + return type.name; +} + +void collectKeys(const TsType &type, std::vector &keys) +{ + if (type.kind == TsType::Kind::Named && std::find(keys.begin(), keys.end(), type.name) == keys.end()) + { + keys.push_back(type.name); + } + + for (auto &pointee : type.pointee) + { + collectKeys(pointee, keys); + } +} + +std::string nameFromKey(const std::string &key) +{ + auto colon = key.find(':'); + return colon == std::string::npos ? key : key.substr(colon + 1); +} + +std::string escapeReserved(std::string name) +{ + // ECMAScript reserved words, including the strict-mode ones + static const char *const reserved[] = { + "await", "break", "case", "catch", "class", "const", "continue", "debugger", + "default", "delete", "do", "else", "enum", "export", "extends", "false", + "finally", "for", "function", "if", "implements", "import", "in", "instanceof", + "interface", "let", "new", "null", "package", "private", "protected", "public", + "return", "static", "super", "switch", "this", "throw", "true", "try", + "typeof", "var", "void", "while", "with", "yield"}; + + for (auto *word : reserved) + { + if (name == word) + { + return name + "_"; + } + } + + return name; +} + +} // namespace tsbindgen +``` + +`tslang/lib/TsClangImporter/CMakeLists.txt` (Task 2 replaces it with the full version): + +```cmake +# C header -> tslang bindings, as a library: tsbindgen is a thin command line over it, and v2's C++ +# wrapper generation is meant to reuse it. It links clang's frontend; tslang itself does not. +set(LLVM_LINK_COMPONENTS + Support + ) + +add_llvm_library(TsClangImporter + BindingModel.cpp + ) +``` + +In `tslang/lib/CMakeLists.txt`, after `add_subdirectory(TypeScriptMemAllocPass)`: + +```cmake +add_subdirectory(TsClangImporter) +``` + +- [ ] **Step 5: Run it to verify it passes** + +Run: `cmake . && cmake --build . --config Release -j 16 --target TsClangImporterTests && ctest -C Release -R unittest-TsClangImporterTests --output-on-failure` +Expected: `100% tests passed, 0 tests failed out of 1` (5 gtest cases inside). + +- [ ] **Step 6: Commit** + +```bash +git add tslang/include/TsClangImporter/BindingModel.h tslang/lib/TsClangImporter tslang/lib/CMakeLists.txt tslang/unittests/TsClangImporter tslang/unittests/CMakeLists.txt +git commit -m "tsbindgen: TsClangImporter library skeleton and binding model + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 2: Parse a header and print its bindings: the type-mapping table + +The whole pipeline lands here, because every test goes header text → clang → model → printed text: `TypeMapper` (C type → `TsType`, struct skip reasons, cycle breaking), `HeaderParser` (clang run, declarations, macros), `BindingPrinter` (selection, names, printing). Task 3 then pins the skip, selection and naming rules with their own tests. + +**Files:** +- Create: `tslang/include/TsClangImporter/TypeMapper.h`, `tslang/lib/TsClangImporter/TypeMapper.cpp` +- Create: `tslang/include/TsClangImporter/HeaderParser.h`, `tslang/lib/TsClangImporter/HeaderParser.cpp` +- Create: `tslang/include/TsClangImporter/BindingPrinter.h`, `tslang/lib/TsClangImporter/BindingPrinter.cpp` +- Modify: `tslang/lib/TsClangImporter/CMakeLists.txt` (full version) +- Create: `tslang/unittests/TsClangImporter/ImporterTestHelper.h`, `TypeMappingTest.cpp`, `ParseErrorTest.cpp` +- Modify: `tslang/unittests/TsClangImporter/CMakeLists.txt` + +**Interfaces:** +- Consumes: Task 1's model. +- Produces: `struct ParseInput { std::string code, fileName; std::vector args; std::vector> virtualFiles; }`; `llvm::Expected parseHeader(const ParseInput &)` (error: `"clang could not parse ''"`, clang's diagnostics already on stderr); `struct PrintOptions { std::vector filters; std::string namespaceName, stripPrefix; std::vector headerLines; }`; `struct PrintResult { std::string text; std::vector warnings; }`; `llvm::Expected printBindings(const HeaderModel &, const PrintOptions &)` (error only for an invalid glob). `class TypeMapper` is internal to the library. + +- [ ] **Step 1: Write the failing tests** + +`tslang/unittests/TsClangImporter/ImporterTestHelper.h` — parses in memory, hermetically: `-ffreestanding -nostdlibinc` leave only clang's own headers from the resource directory, so no SDK is needed and one Windows host checks both targets: + +```cpp +#ifndef TSCLANGIMPORTER_UNITTESTS_IMPORTERTESTHELPER_H +#define TSCLANGIMPORTER_UNITTESTS_IMPORTERTESTHELPER_H + +#include "TsClangImporter/BindingPrinter.h" +#include "TsClangImporter/HeaderParser.h" + +#include "gtest/gtest.h" + +#include +#include +#include + +namespace tsbindgen_test +{ + +inline const char *const windowsTarget = "x86_64-pc-windows-msvc"; +inline const char *const linuxTarget = "x86_64-linux-gnu"; + +struct Generated +{ + std::string text; + std::vector warnings; +}; + +// Parses `header` as the file input.h and prints its bindings. Hermetic: -ffreestanding and +// -nostdlibinc leave only clang's own headers (stdint.h, stddef.h, ...), from the resource directory +// of the LLVM the tests were built with, so no system SDK is needed and one host checks any target. +// `files` are extra headers input.h can include; a path under "sys/" is a system header. +inline Generated generate(const std::string &header, tsbindgen::PrintOptions options = {}, + const std::string &target = windowsTarget, + std::vector> files = {}, + const std::string &language = "c") +{ + tsbindgen::ParseInput input; + input.code = header; + input.fileName = "input.h"; + input.args = {"-x", language, "--target=" + target, "-ffreestanding", "-nostdlibinc", "-isystem", "sys", + "-resource-dir", TSBINDGEN_TEST_RESOURCE_DIR}; + input.virtualFiles = std::move(files); + + auto model = tsbindgen::parseHeader(input); + if (!model) + { + ADD_FAILURE() << llvm::toString(model.takeError()); + return {}; + } + + auto printed = tsbindgen::printBindings(*model, options); + if (!printed) + { + ADD_FAILURE() << llvm::toString(printed.takeError()); + return {}; + } + + return {printed->text, printed->warnings}; +} + +inline std::string text(const std::string &header, const std::string &target = windowsTarget) +{ + return generate(header, {}, target).text; +} + +} // namespace tsbindgen_test + +#endif // TSCLANGIMPORTER_UNITTESTS_IMPORTERTESTHELPER_H +``` + +`tslang/unittests/TsClangImporter/TypeMappingTest.cpp` — one test per row of the spec's mapping table, plus the cycle rule, macro redefinition and per-target struct layout: + +```cpp +// One test per row of the spec's type-mapping table. + +#include "ImporterTestHelper.h" + +#include "gmock/gmock.h" + +using namespace tsbindgen_test; +using testing::HasSubstr; +using testing::Not; + +TEST(TypeMapping, IntegersHaveTheTargetsExactWidth) +{ + auto header = "#include \n" + "int f(signed char a, unsigned short b, long c, unsigned long long d, int8_t e, uint32_t g);\n"; + + EXPECT_THAT(text(header, windowsTarget), + HasSubstr("declare function f(a: s8, b: u16, c: s32, d: u64, e: s8, g: u32): s32;")); + // C long is 32-bit on Windows and 64-bit on Linux: never `long` in the output + EXPECT_THAT(text(header, linuxTarget), + HasSubstr("declare function f(a: s8, b: u16, c: s64, d: u64, e: s8, g: u32): s32;")); +} + +TEST(TypeMapping, StructFieldsHaveTheTargetsWidths) +{ + auto header = "struct Sized { long a; char b; };\n"; + EXPECT_THAT(text(header, windowsTarget), HasSubstr("type Sized = [a: s32, b: s8];")); + EXPECT_THAT(text(header, linuxTarget), HasSubstr("type Sized = [a: s64, b: s8];")); +} + +TEST(TypeMapping, SizeTypesAreIndex) +{ + EXPECT_THAT(text("#include \n#include \n" + "size_t f(ptrdiff_t a, intptr_t b, uintptr_t c);\n"), + HasSubstr("declare function f(a: index, b: index, c: index): index;")); +} + +TEST(TypeMapping, FloatsAndBool) +{ + EXPECT_THAT(text("#include \nbool f(float a, double b, _Bool c);\n"), + HasSubstr("declare function f(a: f32, b: f64, c: boolean): boolean;")); +} + +TEST(TypeMapping, CharPointersAreStrings) +{ + EXPECT_THAT(text("const char *f(char *a, const char *b, char **c);\n"), + HasSubstr("declare function f(a: string, b: string, c: Reference): string;")); +} + +TEST(TypeMapping, PointerToScalarOrStructIsReference) +{ + auto out = text("struct Point { int x; int y; };\nvoid f(int *a, struct Point *p, double *d);\n"); + EXPECT_THAT(out, HasSubstr("type Point = [x: s32, y: s32];")); + EXPECT_THAT(out, HasSubstr("declare function f(a: Reference, p: Reference, d: Reference): void;")); +} + +TEST(TypeMapping, VoidPointerAndIncompleteStructAreOpaque) +{ + auto out = text("typedef struct Counter Counter;\nvoid *f(void *a, Counter *c, struct Other *o);\n"); + EXPECT_THAT(out, HasSubstr("type Counter = Opaque;")); + EXPECT_THAT(out, HasSubstr("type Other = Opaque;")); + EXPECT_THAT(out, HasSubstr("declare function f(a: Opaque, c: Counter, o: Other): Opaque;")); +} + +TEST(TypeMapping, PointerToCxxClassIsOpaque) +{ + auto out = generate("class Engine { public: virtual ~Engine(); int power; };\n" + "extern \"C\" int engine_power(Engine *e);\n", + {}, windowsTarget, {}, "c++") + .text; + EXPECT_THAT(out, HasSubstr("type Engine = Opaque;")); + EXPECT_THAT(out, HasSubstr("declare function engine_power(e: Engine): s32;")); +} + +TEST(TypeMapping, FunctionPointerIsOpaqueWithItsSignature) +{ + auto out = text("int apply(int (*fn)(int, const char *), int v);\ntypedef void (*callback_t)(void *);\n" + "void on(callback_t cb);\n"); + EXPECT_THAT(out, HasSubstr("declare function apply(fn: Opaque /* (p0: s32, p1: string) => s32 */, v: s32): s32;")); + EXPECT_THAT(out, HasSubstr("type callback_t = Opaque; // (p0: Opaque) => void")); + EXPECT_THAT(out, HasSubstr("declare function on(cb: callback_t): void;")); + EXPECT_THAT(out, HasSubstr("pass a function without captures, as `fn as Opaque`")); +} + +TEST(TypeMapping, CompleteStructIsANamedTuple) +{ + EXPECT_THAT(text("#include \ntypedef struct { int8_t a; int32_t b; int64_t c; } Mixed;\n" + "struct Node { int value; struct Node *next; };\n"), + testing::AllOf(HasSubstr("type Mixed = [a: s8, b: s32, c: s64];"), + HasSubstr("type Node = [value: s32, next: Opaque /* Reference: a type cannot refer to itself */];"))); +} + +// tslang cannot declare a type that refers to itself (the compiler dies on one), so every pointer +// field that leads back to its own struct - directly, through another struct, or through a typedef +// - is Opaque +TEST(TypeMapping, TypeCyclesBecomeOpaque) +{ + auto out = text("struct A { struct B *b; int x; };\nstruct B { struct A *a; };\n" + "typedef struct List *ListPtr;\nstruct List { ListPtr next; int v; };\n" + "void walk(struct A *a, ListPtr l);\n"); + EXPECT_THAT(out, HasSubstr("type A = [b: Opaque /* Reference: a type cannot refer to itself */, x: s32];")); + EXPECT_THAT(out, HasSubstr("type B = [a: Opaque /* Reference: a type cannot refer to itself */];")); + EXPECT_THAT(out, HasSubstr("type List = [next: Opaque /* Reference: a type cannot refer to itself */, v: s32];")); + EXPECT_THAT(out, HasSubstr("type ListPtr = Reference;")); + EXPECT_THAT(out, HasSubstr("declare function walk(a: Reference, l: ListPtr): void;")); + EXPECT_THAT(out, Not(HasSubstr("fn as Opaque"))); +} + +TEST(TypeMapping, CEnumIsATsEnum) +{ + auto out = text("enum Color { RED, GREEN = 5, BLUE };\nint f(enum Color c);\n"); + EXPECT_THAT(out, HasSubstr("enum Color { RED = 0, GREEN = 5, BLUE = 6 }")); + EXPECT_THAT(out, HasSubstr("declare function f(c: Color): s32;")); +} + +TEST(TypeMapping, FixedNonIntEnumIsATypeAndConstants) +{ + auto out = generate("#include \n" + "enum Mode : uint8_t { Off = 0, On = 1 };\n" + "enum class Level : int16_t { Low = -1, High = 2 };\n" + "extern \"C\" void set(Mode m, Level l);\n", + {}, windowsTarget, {}, "c++") + .text; + EXPECT_THAT(out, HasSubstr("type Mode = u8;\nconst Off = 0;\nconst On = 1;\n")); + EXPECT_THAT(out, HasSubstr("type Level = s16;\nconst Level_Low = -1;\nconst Level_High = 2;\n")); + EXPECT_THAT(out, HasSubstr("declare function set(m: Mode, l: Level): void;")); +} + +TEST(TypeMapping, VarargsKeepTheFixedParameters) +{ + EXPECT_THAT(text("int print_all(const char *format, ...);\n"), + HasSubstr("@varargs declare function print_all(format: string): s32;")); +} + +TEST(TypeMapping, LiteralMacrosAreConstants) +{ + auto out = text("#define ANSWER 42\n#define NEGATIVE (-7)\n#define MASK 0xFFu\n#define RATIO 1.5\n" + "#define WHOLE 2.0\n#define NAME \"fix\\\"ture\"\n"); + EXPECT_THAT(out, HasSubstr("const ANSWER = 42;")); + EXPECT_THAT(out, HasSubstr("const NEGATIVE = -7;")); + EXPECT_THAT(out, HasSubstr("const MASK = 255;")); + EXPECT_THAT(out, HasSubstr("const RATIO = 1.5;")); + EXPECT_THAT(out, HasSubstr("const WHOLE = 2.0;")); + EXPECT_THAT(out, HasSubstr("const NAME = \"fix\\\"ture\";")); +} + +TEST(TypeMapping, MacrosFollowUndefAndRedefinition) +{ + auto out = text("#define GONE 1\n#undef GONE\n#define LEVEL 1\n#undef LEVEL\n#define LEVEL 2\n"); + EXPECT_THAT(out, Not(HasSubstr("GONE"))); + EXPECT_THAT(out, HasSubstr("const LEVEL = 2;")); + EXPECT_THAT(out, Not(HasSubstr("const LEVEL = 1;"))); +} + +TEST(TypeMapping, TypedefIsATypeAliasUnlessItNamesTheStruct) +{ + auto out = text("typedef struct Point { int x; } Point;\ntypedef struct Point Pt;\ntypedef unsigned int handle_t;\n" + "void f(Pt *p, handle_t h);\n"); + EXPECT_THAT(out, HasSubstr("type Point = [x: s32];")); + EXPECT_THAT(out, Not(HasSubstr("type Point = Point"))); + EXPECT_THAT(out, HasSubstr("type Pt = Point;")); + EXPECT_THAT(out, HasSubstr("type handle_t = u32;")); + EXPECT_THAT(out, HasSubstr("declare function f(p: Reference, h: handle_t): void;")); +} + +TEST(TypeMapping, ParameterNames) +{ + EXPECT_THAT(text("void f(int, int delete, int function, int value);\n"), + HasSubstr("declare function f(p0: s32, delete_: s32, function_: s32, value: s32): void;")); +} +``` + +`tslang/unittests/TsClangImporter/ParseErrorTest.cpp`: + +```cpp +// A header clang rejects, or a command line clang rejects, is an error - never an empty success. + +#include "ImporterTestHelper.h" + +#include "gmock/gmock.h" + +namespace +{ + +llvm::Expected parse(const std::string &code, std::vector extraArgs = {}) +{ + tsbindgen::ParseInput input; + input.code = code; + input.fileName = "input.h"; + input.args = {"-x", "c", "--target=x86_64-pc-windows-msvc", "-ffreestanding", "-nostdlibinc", "-resource-dir", + TSBINDGEN_TEST_RESOURCE_DIR}; + input.args.insert(input.args.end(), extraArgs.begin(), extraArgs.end()); + return tsbindgen::parseHeader(input); +} + +} // namespace + +TEST(ParseError, SyntaxError) +{ + auto model = parse("int broken(\n"); + ASSERT_FALSE(static_cast(model)); + EXPECT_THAT(llvm::toString(model.takeError()), testing::HasSubstr("clang could not parse 'input.h'")); +} + +TEST(ParseError, MissingInclude) +{ + auto model = parse("#include \"nowhere.h\"\nint f(void);\n"); + ASSERT_FALSE(static_cast(model)); + llvm::consumeError(model.takeError()); +} + +TEST(ParseError, ArgumentClangRejects) +{ + auto model = parse("int f(void);\n", {"--no-such-clang-flag"}); + ASSERT_FALSE(static_cast(model)); + llvm::consumeError(model.takeError()); +} + +TEST(ParseError, WarningsAreNotErrors) +{ + auto model = parse("int f(struct Late *l);\n"); + ASSERT_TRUE(static_cast(model)) << llvm::toString(model.takeError()); +} +``` + +`tslang/unittests/TsClangImporter/CMakeLists.txt`: + +```cmake +add_mlir_unittest(TsClangImporterTests + BindingModelTest.cpp + TypeMappingTest.cpp + ParseErrorTest.cpp +) + +target_link_libraries(TsClangImporterTests + PRIVATE + TsClangImporter +) + +# clang's own headers (stdint.h, stddef.h) for the hermetic -ffreestanding -nostdlibinc parses +target_compile_definitions(TsClangImporterTests PRIVATE + TSBINDGEN_TEST_RESOURCE_DIR="${LLVM_LIBRARY_DIR}/clang/${LLVM_VERSION_MAJOR}") +``` + +- [ ] **Step 2: Run them to verify they fail** + +Run: `cmake . && cmake --build . --config Release -j 16 --target TsClangImporterTests` +Expected: FAIL — `fatal error C1083: Cannot open include file: 'TsClangImporter/BindingPrinter.h'`. + +- [ ] **Step 3: Write the type mapper** + +`tslang/include/TsClangImporter/TypeMapper.h`: + +```cpp +#ifndef TSCLANGIMPORTER_TYPEMAPPER_H +#define TSCLANGIMPORTER_TYPEMAPPER_H + +#include "TsClangImporter/BindingModel.h" + +#include "clang/AST/ASTContext.h" +#include "clang/AST/Decl.h" +#include "llvm/ADT/DenseMap.h" +#include "llvm/ADT/StringSet.h" + +#include +#include + +namespace tsbindgen +{ + +// C type -> tslang type, for one translation unit. Widths come from the ASTContext, so they are the +// target's: C `long` is i32 for x86_64-pc-windows-msvc and i64 for x86_64-linux-gnu. +class TypeMapper +{ + public: + enum class Use + { + Parameter, + Result, + Field, + Alias // the right-hand side of a typedef + }; + + explicit TypeMapper(clang::ASTContext &context) : context(context) + { + } + + TsType map(clang::QualType type, Use use); + + // Why a complete struct cannot be a named tuple ("" if it can). Cached; a struct that refers to + // itself through a pointer counts as mappable while it is being checked. + std::string recordSkipReason(const clang::RecordDecl *record); + + // A complete struct's fields. A pointer field that leads back to this struct is Opaque: + // tslang cannot declare a type that refers to itself. + std::vector mapFields(const clang::RecordDecl *definition); + + // The struct/enum's own name, or the typedef that names it when it has none ("" for neither). + static std::string tagName(const clang::TagDecl *tag); + static std::string recordKey(const clang::RecordDecl *record); + static std::string enumKey(const clang::EnumDecl *enumDecl); + static std::string typedefKey(const clang::TypedefNameDecl *typedefDecl); + + // `typedef struct S S;` and `typedef struct { ... } S;` name the struct itself: no alias is emitted. + static bool namesItsTag(const clang::TypedefNameDecl *typedefDecl); + + // A C enum is a TS enum only when it is int-sized and not given a fixed underlying type other + // than int; anything else is a type alias plus constants. + bool isPlainEnum(const clang::EnumDecl *enumDecl); + + // Every struct and enum a mapped type has named, by key. The AST traversal does not reach a + // struct first named inside a prototype (`void f(struct S *s);`), so the parser adds these. + const std::vector> &referencedTags() const + { + return referenced; + } + + private: + std::string referTo(const clang::TagDecl *tag); + TsType mapPointer(const clang::PointerType *pointer, Use use); + TsType mapBuiltin(clang::QualType canonical, Use use); + std::string signatureComment(const clang::FunctionProtoType *function); + bool isSystem(const clang::Decl *decl); + const clang::RecordDecl *recordBehind(clang::QualType type); + bool leadsToOwner(clang::QualType type); + + clang::ASTContext &context; + llvm::DenseMap recordReasons; + std::vector> referenced; + llvm::StringSet<> referencedKeys; + std::vector owners; // structs whose fields are being mapped +}; + +} // namespace tsbindgen + +#endif // TSCLANGIMPORTER_TYPEMAPPER_H +``` + +`tslang/lib/TsClangImporter/TypeMapper.cpp`: + +```cpp +#include "TsClangImporter/TypeMapper.h" + +#include "clang/AST/DeclCXX.h" +#include "clang/AST/RecordLayout.h" +#include "clang/Basic/SourceManager.h" +#include "llvm/ADT/SmallPtrSet.h" +#include "llvm/Support/MathExtras.h" + +namespace tsbindgen +{ + +namespace +{ + +bool isIndexTypedef(llvm::StringRef name) +{ + return name == "size_t" || name == "ssize_t" || name == "ptrdiff_t" || name == "intptr_t" || + name == "uintptr_t"; +} + +bool isPlainChar(clang::QualType canonical) +{ + return canonical->isSpecificBuiltinType(clang::BuiltinType::Char_S) || + canonical->isSpecificBuiltinType(clang::BuiltinType::Char_U); +} + +} // namespace + +std::string TypeMapper::tagName(const clang::TagDecl *tag) +{ + if (!tag->getName().empty()) + { + return tag->getName().str(); + } + + if (auto *typedefDecl = tag->getTypedefNameForAnonDecl()) + { + return typedefDecl->getName().str(); + } + + return ""; +} + +std::string TypeMapper::recordKey(const clang::RecordDecl *record) +{ + return "struct:" + tagName(record); +} + +std::string TypeMapper::enumKey(const clang::EnumDecl *enumDecl) +{ + return "enum:" + tagName(enumDecl); +} + +std::string TypeMapper::typedefKey(const clang::TypedefNameDecl *typedefDecl) +{ + return "typedef:" + typedefDecl->getName().str(); +} + +bool TypeMapper::namesItsTag(const clang::TypedefNameDecl *typedefDecl) +{ + auto *tag = typedefDecl->getUnderlyingType().getCanonicalType()->getAsTagDecl(); + if (!tag) + { + return false; + } + + if (auto *anonName = tag->getTypedefNameForAnonDecl()) + { + return anonName->getCanonicalDecl() == typedefDecl->getCanonicalDecl(); + } + + return tag->getName() == typedefDecl->getName(); +} + +bool TypeMapper::isPlainEnum(const clang::EnumDecl *enumDecl) +{ + auto integer = enumDecl->getIntegerType(); + if (integer.isNull()) + { + return true; + } + + auto canonical = integer.getCanonicalType(); + return context.getIntWidth(canonical) == 32 && + (!enumDecl->isFixed() || canonical->isSpecificBuiltinType(clang::BuiltinType::Int)); +} + +std::string TypeMapper::referTo(const clang::TagDecl *tag) +{ + auto key = llvm::isa(tag) ? enumKey(llvm::cast(tag)) + : recordKey(llvm::cast(tag)); + if (referencedKeys.insert(key).second) + { + referenced.push_back({key, tag}); + } + + return key; +} + +bool TypeMapper::isSystem(const clang::Decl *decl) +{ + auto &sourceManager = context.getSourceManager(); + return sourceManager.isInSystemHeader(sourceManager.getExpansionLoc(decl->getLocation())); +} + +TsType TypeMapper::map(clang::QualType type, Use use) +{ + type = type.getUnqualifiedType(); + + // an array or function parameter, adjusted to a pointer + if (auto *decayed = type->getAs()) + { + return map(decayed->getDecayedType(), use); + } + + if (auto *typedefType = type->getAs()) + { + auto *typedefDecl = typedefType->getDecl(); + if (isIndexTypedef(typedefDecl->getName())) + { + return TsType::builtin("index"); + } + + // a system header's typedefs (int32_t, uint8_t, ...) are looked through: their widths are + // what matter, and the header is not emitted + if (isSystem(typedefDecl)) + { + return map(typedefDecl->getUnderlyingType(), use); + } + + auto aliased = map(typedefDecl->getUnderlyingType(), use); + if (aliased.skipped() || namesItsTag(typedefDecl) || leadsToOwner(typedefDecl->getUnderlyingType())) + { + return aliased; + } + + return TsType::named(typedefKey(typedefDecl)); + } + + auto canonical = type.getCanonicalType().getUnqualifiedType(); + if (canonical->isVoidType()) + { + return use == Use::Result ? TsType::builtin("void") : TsType::skip("void"); + } + + if (auto *pointer = type->getAs()) + { + return mapPointer(pointer, use); + } + + if (canonical->isBuiltinType()) + { + return mapBuiltin(canonical, use); + } + + if (auto *enumType = canonical->getAs()) + { + auto *enumDecl = enumType->getDecl()->getDefinitionOrSelf(); + if (tagName(enumDecl).empty()) + { + // an anonymous enum's values are plain integers of its underlying type + return mapBuiltin(enumDecl->getIntegerType().getCanonicalType(), use); + } + + return TsType::named(referTo(enumDecl)); + } + + if (auto *record = canonical->getAsRecordDecl()) + { + if (use == Use::Parameter || use == Use::Result) + { + return TsType::skip(record->isUnion() ? "union passed by value" : "struct passed by value"); + } + + auto name = tagName(record); + if (name.empty()) + { + return TsType::skip("anonymous struct"); + } + + if (use == Use::Alias) + { + return TsType::named(referTo(record)); + } + + auto *definition = record->getDefinition(); + if (!definition) + { + return TsType::skip("incomplete struct '" + name + "'"); + } + + auto reason = recordSkipReason(definition); + if (!reason.empty()) + { + return TsType::skip("'" + name + "' is skipped (" + reason + ")"); + } + + return TsType::named(referTo(record)); + } + + if (canonical->isArrayType()) + { + return TsType::skip("array"); + } + + if (canonical->isFunctionType()) + { + return TsType::skip("function type"); + } + + return TsType::skip("unsupported type '" + type.getAsString() + "'"); +} + +TsType TypeMapper::mapBuiltin(clang::QualType canonical, Use use) +{ + auto *builtin = canonical->castAs(); + switch (builtin->getKind()) + { + case clang::BuiltinType::Bool: + return TsType::builtin("boolean"); + case clang::BuiltinType::Float: + return TsType::builtin("f32"); + case clang::BuiltinType::Double: + return TsType::builtin("f64"); + case clang::BuiltinType::LongDouble: + return TsType::skip("long double"); + default: + break; + } + + if (canonical->isIntegerType()) + { + auto width = context.getIntWidth(canonical); + if (width == 8 || width == 16 || width == 32 || width == 64) + { + return TsType::builtin((canonical->isSignedIntegerType() ? "s" : "u") + std::to_string(width)); + } + + return TsType::skip(std::to_string(width) + "-bit integer"); + } + + return TsType::skip("unsupported type '" + canonical.getAsString() + "'"); +} + +TsType TypeMapper::mapPointer(const clang::PointerType *pointer, Use use) +{ + auto pointee = pointer->getPointeeType(); + auto canonicalPointee = pointee.getCanonicalType().getUnqualifiedType(); + + if (isPlainChar(canonicalPointee)) + { + return TsType::builtin("string"); + } + + if (canonicalPointee->isVoidType()) + { + return TsType::builtin("Opaque"); + } + + if (canonicalPointee->isFunctionType()) + { + auto *function = pointee->getAs(); + auto type = TsType::builtin("Opaque", function ? signatureComment(function) : "(...) => ?"); + type.functionPointer = true; + return type; + } + + if (auto *record = canonicalPointee->getAsRecordDecl()) + { + if (tagName(record).empty()) + { + return TsType::skip("pointer to an anonymous struct"); + } + + if (leadsToOwner(pointee)) + { + return TsType::builtin("Opaque", "Reference<" + tagName(record) + ">: a type cannot refer to itself"); + } + + // incomplete, a C++ class, or a struct that is itself skipped: the struct is emitted as + // `type S = Opaque;`, and a pointer to it is that S + auto *definition = record->getDefinition(); + if (!definition || !recordSkipReason(definition).empty()) + { + return TsType::named(referTo(record)); + } + + return TsType::reference(TsType::named(referTo(record))); + } + + auto inner = map(pointee, Use::Field); + if (inner.skipped()) + { + return TsType::skip("pointer to " + inner.skipReason); + } + + return TsType::reference(inner); +} + +std::string TypeMapper::signatureComment(const clang::FunctionProtoType *function) +{ + std::string text = "("; + for (unsigned index = 0; index < function->getNumParams(); ++index) + { + auto type = map(function->getParamType(index), Use::Parameter); + text += (index ? ", p" : "p") + std::to_string(index) + ": " + + (type.skipped() ? "?" : renderType(type, nameFromKey)); + } + + if (function->isVariadic()) + { + text += function->getNumParams() ? ", ..." : "..."; + } + + auto result = map(function->getReturnType(), Use::Result); + return text + ") => " + (result.skipped() ? "?" : renderType(result, nameFromKey)); +} + +std::string TypeMapper::recordSkipReason(const clang::RecordDecl *record) +{ + auto found = recordReasons.find(record); + if (found != recordReasons.end()) + { + return found->second; + } + + // a struct that points to itself is mappable while this runs + recordReasons[record] = ""; + owners.push_back(record); + + auto reason = [&]() -> std::string { + if (record->isUnion()) + { + return "union"; + } + + if (auto *cxxRecord = llvm::dyn_cast(record); cxxRecord && !cxxRecord->isCLike()) + { + return "C++ class"; + } + + if (record->field_empty()) + { + return "empty struct"; + } + + // a named tuple is laid out with natural alignment, field after field; anything else would + // put the fields where C does not + const auto &layout = context.getASTRecordLayout(record); + uint64_t offset = 0; + uint64_t maxAlign = 8; + unsigned index = 0; + for (auto *field : record->fields()) + { + auto fieldName = field->getName().str(); + if (field->isBitField()) + { + return "bitfield '" + fieldName + "'"; + } + + if (fieldName.empty()) + { + return "anonymous member"; + } + + auto type = map(field->getType(), Use::Field); + if (type.skipped()) + { + return "field '" + fieldName + "': " + type.skipReason; + } + + auto align = context.getTypeAlign(field->getType()); + offset = llvm::alignTo(offset, align); + if (layout.getFieldOffset(index) != offset) + { + return "packed or over-aligned layout"; + } + + offset += context.getTypeSize(field->getType()); + maxAlign = std::max(maxAlign, align); + ++index; + } + + if (llvm::alignTo(offset, maxAlign) != static_cast(context.toBits(layout.getSize()))) + { + return "packed or over-aligned layout"; + } + + return ""; + }(); + + owners.pop_back(); + recordReasons[record] = reason; + return reason; +} + +std::vector TypeMapper::mapFields(const clang::RecordDecl *definition) +{ + owners.push_back(definition); + std::vector fields; + for (auto *field : definition->fields()) + { + fields.push_back({escapeReserved(field->getName().str()), map(field->getType(), Use::Field)}); + } + + owners.pop_back(); + return fields; +} + +const clang::RecordDecl *TypeMapper::recordBehind(clang::QualType type) +{ + auto canonical = type.getCanonicalType(); + while (true) + { + if (auto *pointer = canonical->getAs()) + { + canonical = pointer->getPointeeType().getCanonicalType(); + } + else if (auto *array = context.getAsArrayType(canonical)) + { + canonical = array->getElementType().getCanonicalType(); + } + else + { + break; + } + } + + auto *record = canonical->getAsRecordDecl(); + return record ? record->getDefinition() : nullptr; +} + +// Whether `type` reaches the struct whose fields are being mapped, through any chain of fields. +bool TypeMapper::leadsToOwner(clang::QualType type) +{ + if (owners.empty()) + { + return false; + } + + llvm::SmallPtrSet visited; + std::vector work; + if (auto *record = recordBehind(type)) + { + work.push_back(record); + } + + while (!work.empty()) + { + auto *record = work.back(); + work.pop_back(); + if (record == owners.back()) + { + return true; + } + + if (!visited.insert(record).second) + { + continue; + } + + for (auto *field : record->fields()) + { + if (auto *next = recordBehind(field->getType())) + { + work.push_back(next); + } + } + } + + return false; +} + +} // namespace tsbindgen +``` + +- [ ] **Step 4: Write the parser** + +It calls `clang::tooling::ToolInvocation` itself instead of `runToolOnCodeWithArgs`, with a `TextDiagnosticPrinter` whose error count covers the command line too — see "Deviations". A struct first named inside a prototype (`void f(struct S *s);`) is not reached by the AST traversal; `finish()` adds every struct and enum the mapper referred to. + +`tslang/include/TsClangImporter/HeaderParser.h`: + +```cpp +#ifndef TSCLANGIMPORTER_HEADERPARSER_H +#define TSCLANGIMPORTER_HEADERPARSER_H + +#include "TsClangImporter/BindingModel.h" + +#include "llvm/Support/Error.h" + +#include +#include +#include + +namespace tsbindgen +{ + +struct ParseInput +{ + std::string code; // the input file's contents + std::string fileName; // its path; `#include "x.h"` resolves next to it + std::vector args; // clang arguments: -x, --target=, -resource-dir, -I, -D, ... + // extra files visible to clang only (tests use them in place of real headers) + std::vector> virtualFiles; +}; + +// Runs clang over the input and maps what it declares. clang's diagnostics go to stderr as clang +// prints them; an error means clang reported an error. +llvm::Expected parseHeader(const ParseInput &input); + +} // namespace tsbindgen + +#endif // TSCLANGIMPORTER_HEADERPARSER_H +``` + +`tslang/lib/TsClangImporter/HeaderParser.cpp`: + +```cpp +#include "TsClangImporter/HeaderParser.h" +#include "TsClangImporter/TypeMapper.h" + +#include "clang/AST/ASTConsumer.h" +#include "clang/AST/DeclCXX.h" +#include "clang/AST/RecursiveASTVisitor.h" +#include "clang/Basic/DiagnosticOptions.h" +#include "clang/Basic/FileManager.h" +#include "clang/Frontend/CompilerInstance.h" +#include "clang/Frontend/FrontendAction.h" +#include "clang/Frontend/TextDiagnosticPrinter.h" +#include "clang/Lex/LiteralSupport.h" +#include "clang/Lex/MacroInfo.h" +#include "clang/Lex/PPCallbacks.h" +#include "clang/Lex/Preprocessor.h" +#include "clang/Tooling/Tooling.h" +#include "llvm/ADT/SmallString.h" +#include "llvm/ADT/StringSet.h" +#include "llvm/Support/MemoryBuffer.h" +#include "llvm/Support/VirtualFileSystem.h" + +namespace tsbindgen +{ + +namespace +{ + +std::string escapeString(llvm::StringRef text) +{ + std::string escaped = "\""; + for (unsigned char c : text) + { + switch (c) + { + case '\\': + escaped += "\\\\"; + break; + case '"': + escaped += "\\\""; + break; + case '\n': + escaped += "\\n"; + break; + case '\r': + escaped += "\\r"; + break; + case '\t': + escaped += "\\t"; + break; + default: + if (c < 0x20 || c == 0x7f) + { + static const char hex[] = "0123456789abcdef"; + escaped += "\\x"; + escaped += hex[c >> 4]; + escaped += hex[c & 0xf]; + } + else + { + escaped += static_cast(c); + } + } + } + + return escaped + "\""; +} + +// An object-like macro's value as a TS literal: an integer, a float or a string, optionally +// negated and parenthesized. "" for anything else. +std::string literalValue(clang::Preprocessor &preprocessor, llvm::ArrayRef tokens) +{ + while (tokens.size() >= 2 && tokens.front().is(clang::tok::l_paren) && tokens.back().is(clang::tok::r_paren)) + { + tokens = tokens.drop_front().drop_back(); + } + + if (!tokens.empty() && llvm::all_of(tokens, [](const clang::Token &token) { return token.is(clang::tok::string_literal); })) + { + clang::StringLiteralParser literal(tokens, preprocessor); + if (literal.hadError || !literal.isOrdinary()) + { + return ""; + } + + return escapeString(literal.GetString()); + } + + auto negative = tokens.size() == 2 && tokens.front().is(clang::tok::minus); + if (negative) + { + tokens = tokens.drop_front(); + } + + if (tokens.size() != 1 || !tokens.front().is(clang::tok::numeric_constant)) + { + return ""; + } + + llvm::SmallString<32> buffer; + auto invalid = false; + auto spelling = preprocessor.getSpelling(tokens.front(), buffer, &invalid); + if (invalid) + { + return ""; + } + + clang::NumericLiteralParser literal(spelling, tokens.front().getLocation(), preprocessor.getSourceManager(), + preprocessor.getLangOpts(), preprocessor.getTargetInfo(), + preprocessor.getDiagnostics()); + if (literal.hadError) + { + return ""; + } + + std::string sign = negative ? "-" : ""; + if (literal.isIntegerLiteral()) + { + llvm::APInt value(64, 0); + if (literal.GetIntegerValue(value)) + { + return ""; // does not fit in 64 bits + } + + llvm::SmallString<32> text; + value.toString(text, 10, /*Signed=*/false); + return sign + text.str().str(); + } + + if (literal.isFloatingLiteral()) + { + llvm::APFloat value(llvm::APFloat::IEEEdouble()); + literal.GetFloatValue(value, llvm::RoundingMode::NearestTiesToEven); + llvm::SmallString<32> text; + value.toString(text); + // "2" would be an integer literal in TS + if (text.find_first_of(".eE") == llvm::StringRef::npos) + { + text += ".0"; + } + + return sign + text.str().str(); + } + + return ""; +} + +class MacroCollector : public clang::PPCallbacks +{ + public: + MacroCollector(clang::Preprocessor &preprocessor, std::vector ¯os) + : preprocessor(preprocessor), macros(macros) + { + } + + void MacroDefined(const clang::Token &nameToken, const clang::MacroDirective *directive) override + { + auto *info = directive->getMacroInfo(); + auto location = info->getDefinitionLoc(); + auto &sourceManager = preprocessor.getSourceManager(); + if (info->isBuiltinMacro() || location.isInvalid() || sourceManager.isWrittenInBuiltinFile(location) || + sourceManager.isWrittenInCommandLineFile(location)) + { + return; + } + + auto name = nameToken.getIdentifierInfo()->getName().str(); + + Decl macro; + macro.kind = DeclKind::Macro; + macro.key = "macro:" + name; + macro.name = name; + macro.inMainFile = sourceManager.isInMainFile(location); + macro.inSystemHeader = sourceManager.isInSystemHeader(location); + if (info->isFunctionLike()) + { + macro.skipReason = "function-like macro"; + } + else if (info->getNumTokens() == 0) + { + forget(name); // an include guard or a feature switch: nothing to bind + return; + } + else + { + macro.value = literalValue(preprocessor, info->tokens()); + if (macro.value.empty()) + { + macro.skipReason = "macro is not a literal"; + } + } + + forget(name); + macros.push_back(std::move(macro)); + } + + void MacroUndefined(const clang::Token &nameToken, const clang::MacroDefinition &, + const clang::MacroDirective *) override + { + forget(nameToken.getIdentifierInfo()->getName().str()); + } + + private: + void forget(const std::string &name) + { + llvm::erase_if(macros, [&](const Decl ¯o) { return macro.name == name; }); + } + + clang::Preprocessor &preprocessor; + std::vector ¯os; +}; + +class Collector : public clang::RecursiveASTVisitor +{ + public: + Collector(clang::ASTContext &context, HeaderModel &model) : context(context), mapper(context), model(model) + { + } + + bool VisitRecordDecl(clang::RecordDecl *record) + { + if (record->isImplicit() || !atFileScope(record)) + { + return true; + } + + if (auto *cxxRecord = llvm::dyn_cast(record); + cxxRecord && (cxxRecord->getDescribedClassTemplate() || + llvm::isa(cxxRecord))) + { + return true; + } + + if (TypeMapper::tagName(record).empty()) + { + return true; // only reachable as a member's type, which skips that member's struct + } + + auto key = TypeMapper::recordKey(record); + if (!keys.insert(key).second) + { + return true; + } + + // completed below, once the whole file is seen: its definition may come later + pendingRecords.push_back({model.decls.size(), record}); + add(DeclKind::OpaqueStruct, key, TypeMapper::tagName(record), record); + return true; + } + + bool VisitEnumDecl(clang::EnumDecl *enumDecl) + { + auto *definition = enumDecl->getDefinition(); + if (definition != enumDecl || !atFileScope(enumDecl)) + { + return true; + } + + auto name = TypeMapper::tagName(enumDecl); + if (name.empty()) + { + std::string listed; + for (auto *enumerator : enumDecl->enumerators()) + { + listed += (listed.empty() ? "" : ", ") + enumerator->getName().str(); + } + + auto &decl = add(DeclKind::Enum, "enum:{" + listed + "}", "enum { " + listed + " }", enumDecl); + decl.skipReason = "anonymous enum"; + return true; + } + + auto key = TypeMapper::enumKey(enumDecl); + if (!keys.insert(key).second) + { + return true; + } + + auto plain = mapper.isPlainEnum(enumDecl); + auto &decl = add(plain ? DeclKind::Enum : DeclKind::FixedEnum, key, name, enumDecl); + decl.scoped = enumDecl->isScoped(); + if (!plain) + { + decl.type = mapper.map(enumDecl->getIntegerType(), TypeMapper::Use::Field); + decl.skipReason = decl.type.skipReason; + } + + for (auto *enumerator : enumDecl->enumerators()) + { + llvm::SmallString<32> value; + enumerator->getInitVal().toString(value, 10); + decl.enumerators.push_back({enumerator->getName().str(), value.str().str()}); + } + + return true; + } + + bool VisitTypedefNameDecl(clang::TypedefNameDecl *typedefDecl) + { + if (isSystem(typedefDecl) || !atFileScope(typedefDecl) || TypeMapper::namesItsTag(typedefDecl)) + { + return true; + } + + auto name = typedefDecl->getName(); + if (name == "size_t" || name == "ssize_t" || name == "ptrdiff_t" || name == "intptr_t" || + name == "uintptr_t") + { + return true; + } + + auto key = TypeMapper::typedefKey(typedefDecl); + if (!keys.insert(key).second) + { + return true; + } + + auto &decl = add(DeclKind::Typedef, key, name.str(), typedefDecl); + decl.type = mapper.map(typedefDecl->getUnderlyingType(), TypeMapper::Use::Alias); + decl.skipReason = decl.type.skipReason; + return true; + } + + bool VisitFunctionDecl(clang::FunctionDecl *function) + { + if (llvm::isa(function) || function->isImplicit() || + function->getTemplatedKind() != clang::FunctionDecl::TK_NonTemplate || !atFileScope(function)) + { + return true; + } + + // C++ proper is v2: only what has C linkage has a C symbol + if (context.getLangOpts().CPlusPlus && !function->isExternC()) + { + return true; + } + + auto name = function->getName().str(); + auto key = "fn:" + name; + if (!keys.insert(key).second) + { + return true; + } + + auto &decl = add(DeclKind::Function, key, name, function); + if (function->getStorageClass() == clang::SC_Static) + { + decl.skipReason = function->isInlineSpecified() ? "static inline function: no symbol to link against" + : "static function: no symbol to link against"; + return true; + } + + auto *prototype = function->getType()->getAs(); + if (!prototype) + { + decl.skipReason = "function without a prototype"; + return true; + } + + decl.varargs = prototype->isVariadic(); + for (unsigned index = 0; index < function->getNumParams(); ++index) + { + auto *parameter = function->getParamDecl(index); + auto parameterName = parameter->getName().empty() ? "p" + std::to_string(index) + : escapeReserved(parameter->getName().str()); + auto type = mapper.map(parameter->getType(), TypeMapper::Use::Parameter); + if (type.skipped()) + { + decl.skipReason = "parameter '" + parameterName + "': " + type.skipReason; + return true; + } + + decl.fields.push_back({parameterName, type}); + } + + decl.type = mapper.map(function->getReturnType(), TypeMapper::Use::Result); + if (decl.type.skipped()) + { + decl.skipReason = "result: " + decl.type.skipReason; + } + + return true; + } + + // After the traversal: complete the structs (a definition may follow the first mention), and + // add every struct or enum a mapped type names that the traversal did not reach, until both + // stop changing - completing a struct maps its fields, which can name more. + void finish() + { + size_t completed = 0; + while (true) + { + std::vector missing; + for (auto &[key, tag] : mapper.referencedTags()) + { + if (!keys.count(key)) + { + missing.push_back(tag); + } + } + + for (auto *tag : missing) + { + if (auto *record = llvm::dyn_cast(tag)) + { + VisitRecordDecl(const_cast(record)); + } + else if (auto *enumDecl = llvm::cast(tag)->getDefinition()) + { + VisitEnumDecl(enumDecl); + } + } + + if (completed == pendingRecords.size()) + { + break; + } + + for (; completed < pendingRecords.size(); ++completed) + { + completeRecord(pendingRecords[completed].first, pendingRecords[completed].second); + } + } + } + + private: + void completeRecord(size_t index, const clang::RecordDecl *record) + { + auto *definition = record->getDefinition(); + if (!definition) + { + return; // stays OpaqueStruct + } + + auto &decl = model.decls[index]; + locate(decl, definition); + decl.kind = DeclKind::Struct; + decl.skipReason = mapper.recordSkipReason(definition); + if (!decl.skipReason.empty()) + { + return; + } + + decl.fields = mapper.mapFields(definition); + } + + bool atFileScope(const clang::Decl *decl) + { + // in C, a struct declared inside another is still file-scoped + return !context.getLangOpts().CPlusPlus || decl->getDeclContext()->getRedeclContext()->isTranslationUnit(); + } + + bool isSystem(const clang::Decl *decl) + { + auto &sourceManager = context.getSourceManager(); + return sourceManager.isInSystemHeader(sourceManager.getExpansionLoc(decl->getLocation())); + } + + void locate(Decl &decl, const clang::Decl *at) + { + auto &sourceManager = context.getSourceManager(); + auto location = sourceManager.getExpansionLoc(at->getLocation()); + decl.inMainFile = sourceManager.isInMainFile(location); + decl.inSystemHeader = sourceManager.isInSystemHeader(location); + } + + Decl &add(DeclKind kind, std::string key, std::string name, const clang::Decl *at) + { + Decl decl; + decl.kind = kind; + decl.key = std::move(key); + decl.name = std::move(name); + locate(decl, at); + model.decls.push_back(std::move(decl)); + return model.decls.back(); + } + + clang::ASTContext &context; + TypeMapper mapper; + HeaderModel &model; + llvm::StringSet<> keys; + std::vector> pendingRecords; +}; + +class ImportConsumer : public clang::ASTConsumer +{ + public: + explicit ImportConsumer(HeaderModel &model) : model(model) + { + } + + void HandleTranslationUnit(clang::ASTContext &context) override + { + Collector collector(context, model); + collector.TraverseDecl(context.getTranslationUnitDecl()); + collector.finish(); + } + + private: + HeaderModel &model; +}; + +class ImportAction : public clang::ASTFrontendAction +{ + public: + ImportAction(HeaderModel &model, std::vector ¯os) : model(model), macros(macros) + { + } + + bool BeginSourceFileAction(clang::CompilerInstance &compiler) override + { + auto &preprocessor = compiler.getPreprocessor(); + preprocessor.addPPCallbacks(std::make_unique(preprocessor, macros)); + return true; + } + + std::unique_ptr CreateASTConsumer(clang::CompilerInstance &, llvm::StringRef) override + { + return std::make_unique(model); + } + + private: + HeaderModel &model; + std::vector ¯os; +}; + +} // namespace + +llvm::Expected parseHeader(const ParseInput &input) +{ + // What clang::tooling::runToolOnCodeWithArgs does, but with our own diagnostic consumer: an + // error in the arguments (`-- --bad-flag`) goes to a diagnostics engine whose errors that + // function never checks, and the run would "succeed" on a command line clang rejected. + auto overlay = llvm::makeIntrusiveRefCnt(llvm::vfs::getRealFileSystem()); + auto memory = llvm::makeIntrusiveRefCnt(); + overlay->pushOverlay(memory); + memory->addFile(input.fileName, 0, llvm::MemoryBuffer::getMemBufferCopy(input.code, input.fileName)); + for (auto &[path, content] : input.virtualFiles) + { + memory->addFile(path, 0, llvm::MemoryBuffer::getMemBufferCopy(content, path)); + } + + auto files = llvm::makeIntrusiveRefCnt(clang::FileSystemOptions(), overlay); + + std::vector commandLine = {"tsbindgen", "-fsyntax-only"}; + commandLine.insert(commandLine.end(), input.args.begin(), input.args.end()); + commandLine.push_back(input.fileName); + + HeaderModel model; + std::vector macros; + clang::tooling::ToolInvocation invocation(commandLine, std::make_unique(model, macros), files.get()); + + clang::DiagnosticOptions diagnosticOptions; + clang::TextDiagnosticPrinter diagnostics(llvm::errs(), diagnosticOptions); + invocation.setDiagnosticConsumer(&diagnostics); + + if (!invocation.run() || diagnostics.getNumErrors() > 0) + { + return llvm::createStringError("clang could not parse '" + input.fileName + "'"); + } + + model.decls.insert(model.decls.begin(), macros.begin(), macros.end()); + return model; +} + +} // namespace tsbindgen +``` + +- [ ] **Step 5: Write the printer** + +Output order is fixed: constants, then types in declaration order, then functions — so a function never names a type declared after it. + +`tslang/include/TsClangImporter/BindingPrinter.h`: + +```cpp +#ifndef TSCLANGIMPORTER_BINDINGPRINTER_H +#define TSCLANGIMPORTER_BINDINGPRINTER_H + +#include "TsClangImporter/BindingModel.h" + +#include "llvm/Support/Error.h" + +#include +#include + +namespace tsbindgen +{ + +struct PrintOptions +{ + // A declaration is selected if its C name matches one of these globs, or, with none, if the + // input file itself declares it. Types a selected declaration uses are emitted too. + std::vector filters; + std::string namespaceName; // --namespace: wrap in `namespace N { export ... }`, with @linkname + std::string stripPrefix; // --strip-prefix: removed from the TS names (needs a namespace) + std::vector headerLines; // printed first, each as a `//` comment +}; + +struct PrintResult +{ + std::string text; + std::vector warnings; // one per skipped declaration, and per name kept unstripped +}; + +// Fails only for a filter that is not a valid glob. +llvm::Expected printBindings(const HeaderModel &model, const PrintOptions &options); + +} // namespace tsbindgen + +#endif // TSCLANGIMPORTER_BINDINGPRINTER_H +``` + +`tslang/lib/TsClangImporter/BindingPrinter.cpp`: + +```cpp +#include "TsClangImporter/BindingPrinter.h" + +#include "llvm/ADT/StringMap.h" +#include "llvm/ADT/StringRef.h" +#include "llvm/Support/GlobPattern.h" + +#include + +namespace tsbindgen +{ + +namespace +{ + +// The selected declarations: every seed, then whatever a non-skipped selected declaration uses. +std::vector selectDecls(const HeaderModel &model, const std::vector &globs) +{ + llvm::StringMap byKey; + for (size_t index = 0; index < model.decls.size(); ++index) + { + byKey[model.decls[index].key] = index; + } + + std::vector selected(model.decls.size(), false); + std::vector work; + auto mark = [&](size_t index) { + if (!selected[index]) + { + selected[index] = true; + work.push_back(index); + } + }; + + for (size_t index = 0; index < model.decls.size(); ++index) + { + auto &decl = model.decls[index]; + auto seed = globs.empty() ? decl.inMainFile + : llvm::any_of(globs, [&](const llvm::GlobPattern &glob) { return glob.match(decl.name); }); + if (seed) + { + mark(index); + } + } + + while (!work.empty()) + { + auto &decl = model.decls[work.back()]; + work.pop_back(); + if (!decl.skipReason.empty()) + { + continue; // printed as Opaque or not at all: it uses nothing + } + + std::vector keys; + for (auto &field : decl.fields) + { + collectKeys(field.type, keys); + } + + collectKeys(decl.type, keys); + for (auto &key : keys) + { + auto found = byKey.find(key); + if (found != byKey.end()) + { + mark(found->second); + } + } + } + + return selected; +} + +int group(DeclKind kind) +{ + switch (kind) + { + case DeclKind::Macro: + return 0; + case DeclKind::Function: + return 2; + default: + return 1; // types, in declaration order, before any function uses them + } +} + +} // namespace + +llvm::Expected printBindings(const HeaderModel &model, const PrintOptions &options) +{ + std::vector globs; + for (auto &filter : options.filters) + { + auto glob = llvm::GlobPattern::create(filter); + if (!glob) + { + return glob.takeError(); + } + + globs.push_back(std::move(*glob)); + } + + auto selected = selectDecls(model, globs); + PrintResult result; + if (llvm::none_of(selected, [](bool isSelected) { return isSelected; })) + { + result.warnings.push_back(options.filters.empty() + ? "nothing to emit: the input file itself declares nothing; pick " + "declarations from its includes with --filter" + : "nothing to emit: no declaration matches --filter"); + } + + // TS names: the C name, minus --strip-prefix unless that leaves nothing, a leading digit, or a + // name another declaration already has + llvm::StringMap tsNames; + llvm::StringMap taken; + for (size_t index = 0; index < model.decls.size(); ++index) + { + if (!selected[index]) + { + continue; + } + + auto &decl = model.decls[index]; + auto name = decl.name; + llvm::StringRef stripped(name); + if (!options.stripPrefix.empty() && stripped.consume_front(options.stripPrefix) && !stripped.empty() && + !std::isdigit(static_cast(stripped.front()))) + { + auto candidate = escapeReserved(stripped.str()); + if (taken.count(candidate)) + { + result.warnings.push_back("kept '" + name + "' unstripped: '" + candidate + "' is taken by '" + + taken[candidate] + "'"); + } + else + { + name = candidate; + } + } + + taken[name] = decl.name; + tsNames[decl.key] = name; + } + + auto nameOf = [&](const std::string &key) { + auto found = tsNames.find(key); + return found != tsNames.end() ? found->second : nameFromKey(key); + }; + + auto inNamespace = !options.namespaceName.empty(); + auto indent = std::string(inNamespace ? " " : ""); + auto exported = std::string(inNamespace ? "export " : ""); + std::string body; + auto line = [&](const std::string &text) { body += indent + text + "\n"; }; + auto hasFunctionPointer = false; + auto withComment = [&](const Field &field) { + auto text = field.name + ": " + renderType(field.type, nameOf); + if (!field.type.comment.empty()) + { + hasFunctionPointer |= field.type.functionPointer; + text += " /* " + field.type.comment + " */"; + } + + return text; + }; + + for (int pass = 0; pass < 3; ++pass) + { + for (size_t index = 0; index < model.decls.size(); ++index) + { + auto &decl = model.decls[index]; + if (!selected[index] || group(decl.kind) != pass) + { + continue; + } + + auto tsName = nameOf(decl.key); + if (!decl.skipReason.empty()) + { + line("// skipped: " + decl.name + " \xE2\x80\x94 " + decl.skipReason); + result.warnings.push_back("skipped " + decl.name + ": " + decl.skipReason); + // a skipped struct keeps its users: a pointer to it is Opaque + if (decl.kind == DeclKind::Struct) + { + line(exported + "type " + tsName + " = Opaque;"); + } + + continue; + } + + switch (decl.kind) + { + case DeclKind::Macro: + line(exported + "const " + tsName + " = " + decl.value + ";"); + break; + case DeclKind::OpaqueStruct: + line(exported + "type " + tsName + " = Opaque;"); + break; + case DeclKind::Struct: { + std::string fields; + for (auto &field : decl.fields) + { + fields += (fields.empty() ? "" : ", ") + withComment(field); + } + + line(exported + "type " + tsName + " = [" + fields + "];"); + break; + } + case DeclKind::Enum: { + std::string members; + for (auto &enumerator : decl.enumerators) + { + members += (members.empty() ? "" : ", ") + enumerator.name + " = " + enumerator.value; + } + + line(exported + "enum " + tsName + " { " + members + " }"); + break; + } + case DeclKind::FixedEnum: + line(exported + "type " + tsName + " = " + renderType(decl.type, nameOf) + ";"); + for (auto &enumerator : decl.enumerators) + { + auto constName = decl.scoped ? tsName + "_" + enumerator.name : enumerator.name; + line(exported + "const " + constName + " = " + enumerator.value + ";"); + } + + break; + case DeclKind::Typedef: { + auto text = exported + "type " + tsName + " = " + renderType(decl.type, nameOf) + ";"; + if (!decl.type.comment.empty()) + { + hasFunctionPointer |= decl.type.functionPointer; + text += " // " + decl.type.comment; + } + + line(text); + break; + } + case DeclKind::Function: { + std::string parameters; + for (auto ¶meter : decl.fields) + { + parameters += (parameters.empty() ? "" : ", ") + withComment(parameter); + } + + std::string decorators = decl.varargs ? "@varargs " : ""; + if (inNamespace) + { + decorators += "@linkname(\"" + decl.name + "\") "; + } + + line(decorators + exported + "declare function " + tsName + "(" + parameters + + "): " + renderType(decl.type, nameOf) + ";"); + break; + } + } + } + } + + for (auto &headerLine : options.headerLines) + { + result.text += "// " + headerLine + "\n"; + } + + if (hasFunctionPointer) + { + result.text += "// A function pointer is Opaque: pass a function without captures, as `fn as Opaque`.\n"; + } + + if (!result.text.empty()) + { + result.text += "\n"; + } + + if (inNamespace) + { + result.text += "namespace " + options.namespaceName + " {\n" + body + "}\n"; + } + else + { + result.text += body; + } + + return result; +} + +} // namespace tsbindgen +``` + +`tslang/lib/TsClangImporter/CMakeLists.txt` (full version): + +```cmake +# C header -> tslang bindings, as a library: tsbindgen is a thin command line over it, and v2's C++ +# wrapper generation is meant to reuse it. It links clang's frontend; tslang itself does not. +set(LLVM_LINK_COMPONENTS + Support + ) + +add_llvm_library(TsClangImporter + BindingModel.cpp + TypeMapper.cpp + HeaderParser.cpp + BindingPrinter.cpp + + LINK_LIBS + clangTooling + clangFrontend + clangAST + clangLex + clangBasic + ) +``` + +- [ ] **Step 6: Run the tests to verify they pass** + +Run: `cmake . && cmake --build . --config Release -j 16 --target TsClangImporterTests && ctest -C Release -R unittest-TsClangImporterTests --output-on-failure` +Expected: `100% tests passed`; the binary itself (`unittests/TsClangImporter/Release/TsClangImporterTests.exe`) prints `[ PASSED ] 27 tests.` — 5 `BindingModel`, 18 `TypeMapping`, 4 `ParseError`. Clang's own warning `declaration of 'struct Late' will not be visible outside of this function` on stderr is expected (`ParseError.WarningsAreNotErrors`). + +- [ ] **Step 7: Commit** + +```bash +git add tslang/include/TsClangImporter tslang/lib/TsClangImporter tslang/unittests/TsClangImporter +git commit -m "tsbindgen: parse C headers with clang and print tslang bindings + +Signed C integers map to sN (tslang's iN are signless), and a struct +pointer that leads back to its own struct is Opaque: tslang cannot +declare a self-referencing type. + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 3: Pin the skip, selection and naming rules + +The code is Task 2's; these tests pin the spec's "Skipped declarations", "What gets emitted" and "Naming" sections so a reviewer can check each rule against the spec independently of the mapping table. They are expected to pass as soon as they build; a failure means Task 2's code deviates from the spec — fix the code, not the test. + +**Files:** +- Create: `tslang/unittests/TsClangImporter/SkipTest.cpp`, `SelectionTest.cpp`, `NamingTest.cpp` +- Modify: `tslang/unittests/TsClangImporter/CMakeLists.txt` + +**Interfaces:** +- Consumes: `generate(header, PrintOptions, target, files, language)` from `ImporterTestHelper.h`; `PrintOptions`, `PrintResult` from Task 2. + +- [ ] **Step 1: Write the tests** + +`tslang/unittests/TsClangImporter/SkipTest.cpp`: + +```cpp +// Every skip reason in the spec: the declaration is left out with a `// skipped:` line and a +// warning, generation carries on, and a skipped struct is still Opaque for its pointer users. + +#include "ImporterTestHelper.h" + +#include "gmock/gmock.h" + +using namespace tsbindgen_test; +using testing::Contains; +using testing::HasSubstr; +using testing::Not; + +TEST(Skip, UnionIsSkippedButItsPointerUsersStay) +{ + auto out = generate("union Value { int i; float f; };\nvoid set(union Value *v);\nvoid pass(union Value v);\n"); + EXPECT_THAT(out.text, HasSubstr("// skipped: Value \xE2\x80\x94 union\ntype Value = Opaque;")); + EXPECT_THAT(out.text, HasSubstr("declare function set(v: Value): void;")); + EXPECT_THAT(out.text, HasSubstr("// skipped: pass \xE2\x80\x94 parameter 'v': union passed by value")); + EXPECT_THAT(out.warnings, Contains("skipped Value: union")); +} + +TEST(Skip, StructWithABitfieldOrAnUnmappableField) +{ + auto out = text("struct Flags { unsigned a : 1; };\nstruct Wide { long double x; };\n" + "struct Grid { int cells[4]; };\n"); + EXPECT_THAT(out, HasSubstr("// skipped: Flags \xE2\x80\x94 bitfield 'a'")); + EXPECT_THAT(out, HasSubstr("// skipped: Wide \xE2\x80\x94 field 'x': long double")); + EXPECT_THAT(out, HasSubstr("// skipped: Grid \xE2\x80\x94 field 'cells': array")); +} + +TEST(Skip, PackedStruct) +{ + EXPECT_THAT(text("struct __attribute__((packed)) Packed { char c; int i; };\n"), + HasSubstr("// skipped: Packed \xE2\x80\x94 packed or over-aligned layout")); + // how real headers usually pack + EXPECT_THAT(text("#pragma pack(push, 1)\nstruct Wire { char c; int i; };\n#pragma pack(pop)\n" + "struct Natural { char c; int i; };\n"), + testing::AllOf(HasSubstr("// skipped: Wire \xE2\x80\x94 packed or over-aligned layout"), + HasSubstr("type Natural = [c: s8, i: s32];"))); +} + +TEST(Skip, LongDouble) +{ + EXPECT_THAT(text("long double f(long double x);\n"), + HasSubstr("// skipped: f \xE2\x80\x94 parameter 'x': long double")); +} + +TEST(Skip, StructByValue) +{ + auto out = text("struct Point { int x; int y; };\nstruct Point make(int x);\nint sum(struct Point p);\n"); + EXPECT_THAT(out, HasSubstr("type Point = [x: s32, y: s32];")); + EXPECT_THAT(out, HasSubstr("// skipped: make \xE2\x80\x94 result: struct passed by value")); + EXPECT_THAT(out, HasSubstr("// skipped: sum \xE2\x80\x94 parameter 'p': struct passed by value")); +} + +TEST(Skip, FunctionLikeAndNonLiteralMacros) +{ + auto out = text("#define MAX(a, b) ((a) > (b) ? (a) : (b))\n#define FLAGS (1 << 3)\n#define GUARD_H\n"); + EXPECT_THAT(out, HasSubstr("// skipped: MAX \xE2\x80\x94 function-like macro")); + EXPECT_THAT(out, HasSubstr("// skipped: FLAGS \xE2\x80\x94 macro is not a literal")); + EXPECT_THAT(out, Not(HasSubstr("GUARD_H"))); +} + +TEST(Skip, StaticInlineFunction) +{ + auto out = text("static inline int twice(int x) { return x * 2; }\nstatic int hidden(void);\n"); + EXPECT_THAT(out, HasSubstr("// skipped: twice \xE2\x80\x94 static inline function: no symbol to link against")); + EXPECT_THAT(out, HasSubstr("// skipped: hidden \xE2\x80\x94 static function: no symbol to link against")); +} + +TEST(Skip, OtherTypes) +{ + auto out = text("_Complex double f(void);\nint g();\n"); + EXPECT_THAT(out, HasSubstr("// skipped: f \xE2\x80\x94 result: unsupported type '_Complex double'")); + EXPECT_THAT(out, HasSubstr("// skipped: g \xE2\x80\x94 function without a prototype")); +} + +TEST(Skip, ASkipDoesNotStopGeneration) +{ + auto out = text("union U { int i; };\nint before(void);\nvoid broken(union U u);\nint after(void);\n"); + EXPECT_THAT(out, HasSubstr("declare function before(): s32;")); + EXPECT_THAT(out, HasSubstr("declare function after(): s32;")); +} +``` + +`tslang/unittests/TsClangImporter/SelectionTest.cpp`: + +```cpp +// What gets emitted: without --filter, what the input file declares; with it, what matches; and in +// both cases the types those use, wherever they are declared. + +#include "ImporterTestHelper.h" + +#include "gmock/gmock.h" + +using namespace tsbindgen_test; +using testing::HasSubstr; +using testing::Not; + +namespace +{ + +const std::vector> files = { + {"other.h", "typedef struct Shared { int id; } Shared;\nint other_unused(void);\n#define OTHER_VALUE 1\n"}, + {"sys/system.h", "struct sys_stat { int size; };\nint sys_call(struct sys_stat *s);\n#define SYS_VALUE 2\n"}, +}; + +} // namespace + +TEST(Selection, ByDefaultOnlyTheInputFilesDeclarations) +{ + auto out = generate("#include \"other.h\"\n#include \nint mine(void);\n", {}, windowsTarget, files).text; + EXPECT_THAT(out, HasSubstr("declare function mine(): s32;")); + EXPECT_THAT(out, Not(HasSubstr("other_unused"))); + EXPECT_THAT(out, Not(HasSubstr("OTHER_VALUE"))); + EXPECT_THAT(out, Not(HasSubstr("sys_call"))); + EXPECT_THAT(out, Not(HasSubstr("SYS_VALUE"))); + EXPECT_THAT(out, Not(HasSubstr("Shared"))); +} + +TEST(Selection, UsedTypesArePulledInFromAnyHeader) +{ + auto out = generate("#include \"other.h\"\n#include \nint use(Shared *s, struct sys_stat *st);\n", {}, + windowsTarget, files) + .text; + EXPECT_THAT(out, HasSubstr("type Shared = [id: s32];")); + EXPECT_THAT(out, HasSubstr("type sys_stat = [size: s32];")); + EXPECT_THAT(out, HasSubstr("declare function use(s: Reference, st: Reference): s32;")); + EXPECT_THAT(out, Not(HasSubstr("other_unused"))); +} + +TEST(Selection, FilterSelectsByCNameAnywhere) +{ + tsbindgen::PrintOptions options; + options.filters = {"sys_*", "mine"}; + auto out = generate("#include \"other.h\"\n#include \nint mine(void);\nint yours(void);\n", options, + windowsTarget, files) + .text; + EXPECT_THAT(out, HasSubstr("declare function mine(): s32;")); + EXPECT_THAT(out, HasSubstr("declare function sys_call(s: Reference): s32;")); + EXPECT_THAT(out, HasSubstr("type sys_stat = [size: s32];")); + EXPECT_THAT(out, Not(HasSubstr("yours"))); + EXPECT_THAT(out, Not(HasSubstr("SYS_VALUE"))); +} + +TEST(Selection, NothingSelectedIsAWarning) +{ + auto out = generate("#include \"other.h\"\n", {}, windowsTarget, files); + EXPECT_EQ(out.text, ""); + ASSERT_EQ(out.warnings.size(), 1u); + EXPECT_THAT(out.warnings.front(), HasSubstr("nothing to emit")); + + tsbindgen::PrintOptions options; + options.filters = {"no_such_*"}; + EXPECT_THAT(generate("int f(void);\n", options).warnings, testing::Contains(HasSubstr("no declaration matches"))); +} + +TEST(Selection, TypesComeBeforeTheFunctionsThatUseThem) +{ + auto out = text("int first(struct Late *l);\nstruct Late { int x; };\n"); + auto type = out.find("type Late"); + auto function = out.find("declare function first"); + ASSERT_NE(type, std::string::npos); + ASSERT_NE(function, std::string::npos); + EXPECT_LT(type, function); +} +``` + +`tslang/unittests/TsClangImporter/NamingTest.cpp`: + +```cpp +// --namespace wraps the output and binds each function by its C name with @linkname; +// --strip-prefix shortens the TS names but never the symbol. + +#include "ImporterTestHelper.h" + +#include "gmock/gmock.h" + +using namespace tsbindgen_test; +using testing::Contains; +using testing::HasSubstr; + +TEST(Naming, CNamesAreKept) +{ + auto out = text("#define FX_ANSWER 42\nint fx_add(int a, int b);\n"); + EXPECT_EQ(out, "const FX_ANSWER = 42;\ndeclare function fx_add(a: s32, b: s32): s32;\n"); +} + +TEST(Naming, NamespaceExportsEverythingAndLinksByCName) +{ + tsbindgen::PrintOptions options; + options.namespaceName = "Fx"; + auto out = generate("#define FX_ANSWER 42\nstruct fx_point { int x; };\nint fx_add(int a, int b);\n" + "int fx_sum(int n, ...);\n", + options) + .text; + EXPECT_EQ(out, "namespace Fx {\n" + " export const FX_ANSWER = 42;\n" + " export type fx_point = [x: s32];\n" + " @linkname(\"fx_add\") export declare function fx_add(a: s32, b: s32): s32;\n" + " @varargs @linkname(\"fx_sum\") export declare function fx_sum(n: s32): s32;\n" + "}\n"); +} + +TEST(Naming, StripPrefixRenamesDeclarationsAndTheirUses) +{ + tsbindgen::PrintOptions options; + options.namespaceName = "Fx"; + options.stripPrefix = "fx_"; + auto out = generate("struct fx_point { int x; };\nint fx_len(struct fx_point *p);\nint other(void);\n", options) + .text; + EXPECT_THAT(out, HasSubstr(" export type point = [x: s32];\n")); + EXPECT_THAT(out, HasSubstr(" @linkname(\"fx_len\") export declare function len(p: Reference): s32;\n")); + EXPECT_THAT(out, HasSubstr(" @linkname(\"other\") export declare function other(): s32;\n")); +} + +TEST(Naming, StripPrefixKeepsTheCNameWhenStrippingWouldBreakIt) +{ + tsbindgen::PrintOptions options; + options.namespaceName = "Fx"; + options.stripPrefix = "fx_"; + auto out = generate("int len(void);\nint fx_len(void);\nint fx_2d(void);\nint fx_delete(void);\n", options); + EXPECT_THAT(out.text, HasSubstr("@linkname(\"len\") export declare function len(): s32;")); + EXPECT_THAT(out.text, HasSubstr("@linkname(\"fx_len\") export declare function fx_len(): s32;")); + EXPECT_THAT(out.text, HasSubstr("@linkname(\"fx_2d\") export declare function fx_2d(): s32;")); + EXPECT_THAT(out.text, HasSubstr("@linkname(\"fx_delete\") export declare function delete_(): s32;")); + EXPECT_THAT(out.warnings, Contains("kept 'fx_len' unstripped: 'len' is taken by 'len'")); +} + +TEST(Naming, HeaderLinesComeFirst) +{ + tsbindgen::PrintOptions options; + options.headerLines = {"Generated by tsbindgen dev (clang 22.1.8); do not edit.", "tsbindgen input.h"}; + EXPECT_EQ(generate("int f(void);\n", options).text, + "// Generated by tsbindgen dev (clang 22.1.8); do not edit.\n" + "// tsbindgen input.h\n" + "\n" + "declare function f(): s32;\n"); +} +``` + +`tslang/unittests/TsClangImporter/CMakeLists.txt` (final version): + +```cmake +add_mlir_unittest(TsClangImporterTests + BindingModelTest.cpp + TypeMappingTest.cpp + SkipTest.cpp + SelectionTest.cpp + NamingTest.cpp + ParseErrorTest.cpp +) + +target_link_libraries(TsClangImporterTests + PRIVATE + TsClangImporter +) + +# clang's own headers (stdint.h, stddef.h) for the hermetic -ffreestanding -nostdlibinc parses +target_compile_definitions(TsClangImporterTests PRIVATE + TSBINDGEN_TEST_RESOURCE_DIR="${LLVM_LIBRARY_DIR}/clang/${LLVM_VERSION_MAJOR}") +``` + +- [ ] **Step 2: Run them** + +Run: `cmake . && cmake --build . --config Release -j 16 --target TsClangImporterTests && ctest -C Release -R unittest-TsClangImporterTests --output-on-failure` +Expected: `100% tests passed`; the binary itself prints `[ PASSED ] 46 tests.` — Task 2's 27 plus 9 `Skip`, 5 `Selection`, 5 `Naming`. + +- [ ] **Step 3: Commit** + +```bash +git add tslang/unittests/TsClangImporter +git commit -m "tsbindgen: tests for skips, selection and naming + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 4: The `tsbindgen` command line + +**Files:** +- Create: `tslang/tsbindgen/tsbindgen.cpp`, `tslang/tsbindgen/CMakeLists.txt` +- Modify: `tslang/CMakeLists.txt` (after `add_subdirectory(tslang)`) +- Create: `tslang/test/bindgen/run-cli-test.cmake` +- Create: `tslang/test/bindgen/fixture.h` (Task 5 uses it too; written here because the CLI test reads it) +- Modify: `tslang/test/tester/CMakeLists.txt` (before `set(TSLANG_OWNERSHIP_SHARDS 8)`) + +**Interfaces:** +- Consumes: `parseHeader`, `printBindings` (Task 2). +- Produces: `bin/tsbindgen.exe` (`bin/tsbindgen` on Linux), next to `tslang`. Command line per the spec, with `-o` defaulting to stdout. `--version` prints `tsbindgen (clang )`. + +- [ ] **Step 1: Write the fixture header** + +`tslang/test/bindgen/fixture.h` — one of each thing v1 binds, and one of each skip that must not break the rest. `FIXTURE_API` exports the functions from the shared library Task 5 builds; without it a Windows DLL exports nothing and the JIT reports `Symbols not found`. + +```c +// One of each thing tsbindgen v1 binds, for test/bindgen/run-bindgen-test.cmake. bindgen_test.ts +// calls every function through the generated bindings; expected.txt is what it must print. + +#ifndef FIXTURE_H +#define FIXTURE_H + +#include +#include +#include + +#if defined(_WIN32) +#define FIXTURE_API __declspec(dllexport) +#else +#define FIXTURE_API __attribute__((visibility("default"))) +#endif + +#define FIXTURE_ANSWER 42 +#define FIXTURE_RATIO 1.5 +#define FIXTURE_NAME "fixture" + +typedef struct Counter Counter; // opaque handle + +typedef struct Point +{ + int32_t x; + int32_t y; +} Point; + +typedef struct Box +{ + int8_t tag; + Point corner; // a struct inside a struct + double scale; + bool visible; +} Box; + +typedef struct Node // points to itself: `next` becomes Opaque +{ + int32_t value; + struct Node *next; +} Node; + +enum Color +{ + RED, + GREEN = 5, + BLUE +}; + +union Value // skipped, but a pointer to it is still Opaque +{ + int32_t i; + float f; +}; + +// scalars +FIXTURE_API int32_t fx_add(int32_t a, int32_t b); +FIXTURE_API double fx_scale(double v, float f); +FIXTURE_API int32_t fx_widen_s8(int8_t v); +FIXTURE_API int32_t fx_widen_u16(uint16_t v); +FIXTURE_API int8_t fx_negate_s8(int8_t v); +FIXTURE_API bool fx_not(bool b); + +// strings in and out +FIXTURE_API size_t fx_len(const char *s); +FIXTURE_API const char *fx_greet(void); + +// out-parameters +FIXTURE_API void fx_divmod(int32_t a, int32_t b, int32_t *quotient, int32_t *remainder); + +// an opaque handle +FIXTURE_API Counter *fx_counter_new(void); +FIXTURE_API void fx_counter_inc(Counter *c); +FIXTURE_API int32_t fx_counter_get(Counter *c); +FIXTURE_API void fx_counter_free(Counter *c); + +// structs by pointer, read and written by C +FIXTURE_API int32_t fx_point_sum(const Point *p); +FIXTURE_API void fx_box_fill(Box *b); +FIXTURE_API int32_t fx_list_sum(Node *head); + +// an enum +FIXTURE_API int32_t fx_color_value(enum Color c); + +// a callback +FIXTURE_API int32_t fx_apply(int32_t (*fn)(int32_t), int32_t v); + +// varargs +FIXTURE_API int32_t fx_sum(int32_t count, ...); + +// skipped declarations +FIXTURE_API void fx_value_clear(union Value *v); +static inline int32_t fx_twice_inline(int32_t x) +{ + return x * 2; +} + +#endif // FIXTURE_H +``` + +- [ ] **Step 2: Write the failing test** + +`tslang/test/bindgen/run-cli-test.cmake`: + +```cmake +# tsbindgen's command line: 0 when the file was written, 1 for a parse error or I/O failure, 2 for +# bad arguments - and never 0 for a command line clang rejected. + +cmake_minimum_required(VERSION 3.17.3) + +foreach(var TSBINDGEN SOURCE_DIR WORK_DIR) + if(NOT DEFINED ${var}) + message(FATAL_ERROR "${var} is required") + endif() +endforeach() + +file(REMOVE_RECURSE "${WORK_DIR}") +file(MAKE_DIRECTORY "${WORK_DIR}") +file(WRITE "${WORK_DIR}/broken.h" "int broken(\n") + +# expect( ) - leaves stdout + stderr in cli_output +function(expect code what) + execute_process(COMMAND "${TSBINDGEN}" ${ARGN} + WORKING_DIRECTORY "${WORK_DIR}" + OUTPUT_VARIABLE out + ERROR_VARIABLE err + RESULT_VARIABLE status) + if(NOT status EQUAL code) + message(FATAL_ERROR "${what}: exit ${status}, expected ${code}\n${out}\n${err}") + endif() + set(cli_output "${out}${err}" PARENT_SCOPE) + message(STATUS "${what}: exit ${code}") +endfunction() + +set(header "${SOURCE_DIR}/fixture.h") + +expect(0 "a header, written to a file" "${header}" -o out.ts) +if(NOT EXISTS "${WORK_DIR}/out.ts") + message(FATAL_ERROR "exit 0, but out.ts was not written") +endif() + +# `import` would load a same-named library instead, so the file says how to include it +file(READ "${WORK_DIR}/out.ts" written) +if(NOT written MATCHES "// Include with: /// ") + message(FATAL_ERROR "out.ts lacks the include hint:\n${written}") +endif() + +expect(0 "a header, to stdout" "${header}" --filter fx_add) +if(NOT cli_output MATCHES "declare function fx_add\\(a: s32, b: s32\\): s32;") + message(FATAL_ERROR "stdout lacks fx_add:\n${cli_output}") +endif() + +expect(0 "--version" --version) +if(NOT cli_output MATCHES "tsbindgen .* \\(clang [0-9]+") + message(FATAL_ERROR "--version printed:\n${cli_output}") +endif() + +expect(0 "a .d.ts output warns" "${header}" -o out.d.ts) +if(NOT cli_output MATCHES "name it \\.ts") + message(FATAL_ERROR "no .d.ts warning:\n${cli_output}") +endif() + +expect(1 "a missing input" missing.h) +expect(1 "a header clang rejects" broken.h) +expect(1 "an argument clang rejects" "${header}" -- --no-such-clang-flag) +expect(1 "an unwritable output" "${header}" -o "${WORK_DIR}/no/such/dir/out.ts") + +expect(2 "no input" --filter x) +expect(2 "an unknown option" "${header}" --no-such-option) +expect(2 "--strip-prefix without --namespace" "${header}" --strip-prefix fx_) +expect(2 "an invalid glob" "${header}" --filter "[") +expect(2 "a --resource-dir without clang's headers" "${header}" --resource-dir "${WORK_DIR}") +``` + +Register it in `tslang/test/tester/CMakeLists.txt`, immediately before `set(TSLANG_OWNERSHIP_SHARDS 8)` (Task 5 adds its own registration next to this one): + +```cmake +add_test(NAME test-bindgen-cli + COMMAND ${CMAKE_COMMAND} + "-DTSBINDGEN=$" + "-DSOURCE_DIR=${PROJECT_SOURCE_DIR}/test/bindgen" + "-DWORK_DIR=${CMAKE_CURRENT_BINARY_DIR}/bindgen-cli" + -P "${PROJECT_SOURCE_DIR}/test/bindgen/run-cli-test.cmake") +``` + +- [ ] **Step 3: Run it to verify it fails** + +Run: `cmake .` +Expected: FAIL — `Error evaluating generator expression: $ No target "tsbindgen"`. + +- [ ] **Step 4: Write the command line** + +`tslang/tsbindgen/tsbindgen.cpp`: + +```cpp +// tsbindgen: C header -> tslang bindings. See docs/superpowers/specs/2026-09-25-tsbindgen-linkname-design.md. + +#include "TsClangImporter/BindingPrinter.h" +#include "TsClangImporter/HeaderParser.h" + +#include "clang/Basic/Version.h" +#include "llvm/Support/CommandLine.h" +#include "llvm/Support/FileSystem.h" +#include "llvm/Support/MemoryBuffer.h" +#include "llvm/Support/Path.h" +#include "llvm/Support/WithColor.h" +#include "llvm/Support/raw_ostream.h" +#include "llvm/TargetParser/Host.h" + +#include + +namespace cl = llvm::cl; + +namespace +{ + +cl::OptionCategory category("tsbindgen options"); + +cl::opt inputFile(cl::Positional, cl::desc(""), cl::Required, cl::cat(category)); +cl::list includeDirs("I", cl::Prefix, cl::desc("Add an include directory"), cl::value_desc("dir"), + cl::cat(category)); +cl::list defines("D", cl::Prefix, cl::desc("Define a macro"), cl::value_desc("name[=value]"), + cl::cat(category)); +cl::opt target("target", cl::desc("Target triple (default: the host)"), cl::value_desc("triple"), + cl::cat(category)); +cl::list filters("filter", cl::desc("Emit the declarations whose C name matches this glob"), + cl::value_desc("glob"), cl::cat(category)); +cl::opt namespaceName("namespace", cl::desc("Wrap the output in `namespace `"), + cl::value_desc("N"), cl::cat(category)); +cl::opt stripPrefix("strip-prefix", cl::desc("Remove

from the TS names (needs --namespace)"), + cl::value_desc("P"), cl::cat(category)); +cl::opt resourceDir("resource-dir", cl::desc("clang's resource directory"), cl::value_desc("dir"), + cl::cat(category)); +cl::opt outputFile("o", cl::desc("Output file (default: stdout)"), cl::value_desc("out.ts"), + cl::init("-"), cl::cat(category)); + +enum ExitCode +{ + Written = 0, + ParseOrIOError = 1, + BadArguments = 2 +}; + +std::string version() +{ +#ifdef TSBINDGEN_VERSION + return TSBINDGEN_VERSION; +#else + return "dev"; +#endif +} + +bool hasStddef(llvm::StringRef dir) +{ + llvm::SmallString<256> path(dir); + llvm::sys::path::append(path, "include", "stddef.h"); + return llvm::sys::fs::exists(path); +} + +// clang's own stddef.h/stdint.h live here; without them those headers do not resolve. +std::optional findResourceDir(llvm::StringRef exePath, std::vector &tried) +{ + auto versioned = [](llvm::StringRef base) { + llvm::SmallString<256> path(base); + llvm::sys::path::append(path, "lib", "clang", std::to_string(CLANG_VERSION_MAJOR)); + return std::string(path); + }; + + std::vector candidates; + if (!resourceDir.empty()) + { + candidates.push_back(resourceDir); + } + else + { + auto exeDir = llvm::sys::path::parent_path(exePath); + candidates.push_back(versioned(exeDir)); // the release package + candidates.push_back(versioned(llvm::sys::path::parent_path(exeDir))); // an LLVM-style install +#ifdef TSBINDGEN_CONFIGURED_RESOURCE_DIR + candidates.push_back(TSBINDGEN_CONFIGURED_RESOURCE_DIR); // the LLVM this was built with +#endif + } + + for (auto &candidate : candidates) + { + tried.push_back(candidate); + if (hasStddef(candidate)) + { + return candidate; + } + } + + return std::nullopt; +} + +bool isCxx(llvm::StringRef path) +{ + auto extension = llvm::sys::path::extension(path).lower(); + return extension == ".cpp" || extension == ".cc" || extension == ".cxx" || extension == ".hpp" || + extension == ".hh" || extension == ".hxx"; +} + +} // namespace + +int main(int argc, char **argv) +{ + // everything after `--` goes to clang untouched + std::vector ownArgs; + std::vector extraArgs; + auto afterDashes = false; + for (int index = 0; index < argc; ++index) + { + if (afterDashes) + { + extraArgs.push_back(argv[index]); + } + else if (index > 0 && llvm::StringRef(argv[index]) == "--") + { + afterDashes = true; + } + else + { + ownArgs.push_back(argv[index]); + } + } + + cl::HideUnrelatedOptions(category); + cl::SetVersionPrinter([](llvm::raw_ostream &os) { + os << "tsbindgen " << version() << " (clang " << CLANG_VERSION_STRING << ")\n"; + }); + + if (!cl::ParseCommandLineOptions(static_cast(ownArgs.size()), ownArgs.data(), + "C header -> tslang bindings\n", &llvm::errs())) + { + return BadArguments; + } + + if (!stripPrefix.empty() && namespaceName.empty()) + { + llvm::WithColor::error(llvm::errs(), "tsbindgen") << "--strip-prefix needs --namespace\n"; + return BadArguments; + } + + std::vector tried; + auto exePath = llvm::sys::fs::getMainExecutable(argv[0], reinterpret_cast(&main)); + auto foundResourceDir = findResourceDir(exePath, tried); + if (!foundResourceDir) + { + auto &error = llvm::WithColor::error(llvm::errs(), "tsbindgen"); + error << "clang's resource directory not found; tried:"; + for (auto &path : tried) + { + error << "\n " << path; + } + + error << "\n(pass --resource-dir

, the directory with include/stddef.h)\n"; + // a wrong --resource-dir is the caller's argument; a failed lookup is a broken install + return resourceDir.empty() ? ParseOrIOError : BadArguments; + } + + auto buffer = llvm::MemoryBuffer::getFile(inputFile); + if (!buffer) + { + llvm::WithColor::error(llvm::errs(), "tsbindgen") + << "cannot read '" << inputFile << "': " << buffer.getError().message() << "\n"; + return ParseOrIOError; + } + + llvm::SmallString<256> absoluteInput(inputFile); + llvm::sys::fs::make_absolute(absoluteInput); + + auto triple = target.empty() ? llvm::sys::getDefaultTargetTriple() : target.getValue(); + + tsbindgen::ParseInput input; + input.code = (*buffer)->getBuffer().str(); + input.fileName = std::string(absoluteInput); + input.args = {"-x", isCxx(inputFile) ? "c++" : "c", "--target=" + triple, "-resource-dir", *foundResourceDir}; + for (auto &dir : includeDirs) + { + input.args.push_back("-I" + dir); + } + + for (auto &define : defines) + { + input.args.push_back("-D" + define); + } + + input.args.insert(input.args.end(), extraArgs.begin(), extraArgs.end()); + + auto model = tsbindgen::parseHeader(input); + if (!model) + { + llvm::WithColor::error(llvm::errs(), "tsbindgen") << llvm::toString(model.takeError()) << "\n"; + return ParseOrIOError; + } + + std::string commandLine = "tsbindgen"; + for (int index = 1; index < argc; ++index) + { + commandLine += " "; + commandLine += argv[index]; + } + + tsbindgen::PrintOptions options; + options.filters = filters; + options.namespaceName = namespaceName; + options.stripPrefix = stripPrefix; + options.headerLines = {"Generated by tsbindgen " + version() + " (clang " CLANG_VERSION_STRING "); do not edit.", + commandLine}; + // `import` would not do: it loads a same-named .dll/.so as a tslang library if there is one, + // and includes a source file as declarations only, so its constants would have no value + if (outputFile != "-") + { + options.headerLines.push_back("Include with: /// "); + } + + auto printed = tsbindgen::printBindings(*model, options); + if (!printed) + { + llvm::WithColor::error(llvm::errs(), "tsbindgen") << llvm::toString(printed.takeError()) << "\n"; + return BadArguments; + } + + for (auto &warning : printed->warnings) + { + llvm::WithColor::warning(llvm::errs(), "tsbindgen") << warning << "\n"; + } + + // A .d.ts is included as declarations only: a `const` in it has no value and fails to link. + if (llvm::StringRef(outputFile.getValue()).ends_with(".d.ts") && + llvm::StringRef(printed->text).contains("const ")) + { + llvm::WithColor::warning(llvm::errs(), "tsbindgen") + << "'" << outputFile << "' declares constants, which a .d.ts file cannot define; name it .ts\n"; + } + + std::error_code error; + llvm::raw_fd_ostream out(outputFile, error, llvm::sys::fs::OF_Text); + if (error) + { + llvm::WithColor::error(llvm::errs(), "tsbindgen") + << "cannot write '" << outputFile << "': " << error.message() << "\n"; + return ParseOrIOError; + } + + out << printed->text; + out.close(); + if (out.has_error()) + { + llvm::WithColor::error(llvm::errs(), "tsbindgen") << "cannot write '" << outputFile << "'\n"; + out.clear_error(); + return ParseOrIOError; + } + + return Written; +} +``` + +`tslang/tsbindgen/CMakeLists.txt`: + +```cmake +set_Options() + +set(LLVM_LINK_COMPONENTS + Support + TargetParser + ) + +add_llvm_executable(tsbindgen + tsbindgen.cpp + ) + +llvm_update_compile_flags(tsbindgen) +target_link_libraries(tsbindgen PRIVATE TsClangImporter) + +# The last place tsbindgen looks for clang's resource directory (stddef.h, stdint.h, ...): the +# LLVM it was built with. A release package puts one next to tsbindgen instead. +target_compile_definitions(tsbindgen PRIVATE + TSBINDGEN_CONFIGURED_RESOURCE_DIR="${LLVM_LIBRARY_DIR}/clang/${LLVM_VERSION_MAJOR}") + +# the same version tslang reports: the release tag, or the commit a dev build was made from +if(TSLANG_PACKAGE_VERSION) + set(TSBINDGEN_VERSION "${TSLANG_PACKAGE_VERSION}") +else() + find_package(Git QUIET) + if(GIT_EXECUTABLE) + execute_process( + COMMAND ${GIT_EXECUTABLE} rev-parse --short HEAD + WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} + OUTPUT_VARIABLE TSBINDGEN_VERSION + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET) + endif() +endif() + +if(TSBINDGEN_VERSION) + target_compile_definitions(tsbindgen PRIVATE TSBINDGEN_VERSION="${TSBINDGEN_VERSION}") +endif() +``` + +In `tslang/CMakeLists.txt`, after `add_subdirectory(tslang)`: + +```cmake +add_subdirectory(tsbindgen) +``` + +- [ ] **Step 5: Run it to verify it passes** + +Run: `cmake . && cmake --build . --config Release -j 16 --target tsbindgen && ctest -C Release -R test-bindgen-cli --output-on-failure` +Expected: `100% tests passed, 0 tests failed out of 1`. With `-V`, 13 `-- : exit ` lines, from `a header, written to a file: exit 0` to `a --resource-dir without clang's headers: exit 2`. + +- [ ] **Step 6: Try it by hand** + +Run: `I:/TypeScriptCompiler/__build/tslang/windows-msbuild-2026-release/bin/tsbindgen.exe I:/TypeScriptCompiler/tslang/test/bindgen/fixture.h` +Expected on stdout (header lines, then): + +```ts +// skipped: FIXTURE_API — macro is not a literal +const FIXTURE_ANSWER = 42; +const FIXTURE_RATIO = 1.5; +const FIXTURE_NAME = "fixture"; +type Counter = Opaque; +type Point = [x: s32, y: s32]; +type Box = [tag: s8, corner: Point, scale: f64, visible: boolean]; +type Node = [value: s32, next: Opaque /* Reference: a type cannot refer to itself */]; +enum Color { RED = 0, GREEN = 5, BLUE = 6 } +// skipped: Value — union +type Value = Opaque; +declare function fx_add(a: s32, b: s32): s32; +``` + +…through `declare function fx_value_clear(v: Value): void;` and `// skipped: fx_twice_inline — static inline function: no symbol to link against`; on stderr, three `tsbindgen: warning: skipped ...` lines. + +- [ ] **Step 7: Commit** + +```bash +git add tslang/tsbindgen tslang/CMakeLists.txt tslang/test/bindgen/fixture.h tslang/test/bindgen/run-cli-test.cmake tslang/test/tester/CMakeLists.txt +git commit -m "tsbindgen: command line, with exit codes pinned by test-bindgen-cli + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 5: End to end — call a real C library through generated bindings + +**Files:** +- Create: `tslang/test/bindgen/fixture.c`, `bindgen_test.ts`, `bindgen_ns_test.ts`, `expected.txt`, `run-bindgen-test.cmake` +- Modify: `tslang/test/tester/CMakeLists.txt` (next to `test-bindgen-cli`) + +**Interfaces:** +- Consumes: `tsbindgen` (Task 4), `tslang`, `fixture.h` (Task 4); `TEST_GC_LIBDIR`, `defaultlib_collector_llvm_lib`, `defaultlib_collector_opt` (already defined above the insertion point in `test/tester/CMakeLists.txt`); `LLVM_TOOLS_BINARY_DIR` (from `LLVMConfig`, the directory with `clang`). + +- [ ] **Step 1: Write the programs and the expected output** + +`tslang/test/bindgen/bindgen_test.ts` — note `/// `, not `import`, and `// @strict-null false` for the `null` list terminator: + +```ts +// @strict-null false +/// + +// Calls everything in fixture.h through the bindings tsbindgen generated from it (fixture.ts). +// run-bindgen-test.cmake compares what this prints with expected.txt. + +function twice(x: s32): s32 { + return x * 2; +} + +function main() { + print("macros", FIXTURE_ANSWER, FIXTURE_RATIO, FIXTURE_NAME); + print("add", fx_add(2, 3)); + print("scale", fx_scale(2.0, 1.5)); + print("narrow", fx_widen_s8(-5), fx_widen_u16(65535), fx_negate_s8(5), fx_not(true), fx_not(false)); + print("len", fx_len("hello")); + print("greet", fx_greet()); + + let quotient: s32 = 0; + let remainder: s32 = 0; + fx_divmod(17, 5, ReferenceOf(quotient), ReferenceOf(remainder)); + print("divmod", quotient, remainder); + + const counter = fx_counter_new(); + fx_counter_inc(counter); + fx_counter_inc(counter); + print("counter", fx_counter_get(counter)); + fx_counter_free(counter); + + let point: Point = [3, 4]; + print("point", fx_point_sum(ReferenceOf(point))); + + let box: Box = [0, [0, 0], 0.0, false]; + fx_box_fill(ReferenceOf(box)); + print("box", box[0], box[1][0], box[1][1], box[2], box[3]); + + let second: Node = [5, null]; + let first: Node = [3, ReferenceOf(second) as Opaque]; + print("list", fx_list_sum(ReferenceOf(first))); + + print("color", fx_color_value(Color.BLUE)); + print("apply", fx_apply(twice as Opaque, 21)); + print("sum", fx_sum(3, 1, 2, 3)); +} +``` + +`tslang/test/bindgen/bindgen_ns_test.ts` — the same calls through `--namespace Fx --strip-prefix fx_` (needs PR 1): + +```ts +// @strict-null false +/// + +// bindgen_test.ts again, against `tsbindgen fixture.h --namespace Fx --strip-prefix fx_`: every +// function is reached through a TS name that is not its C symbol, which @linkname binds. + +function twice(x: s32): s32 { + return x * 2; +} + +function main() { + print("macros", Fx.FIXTURE_ANSWER, Fx.FIXTURE_RATIO, Fx.FIXTURE_NAME); + print("add", Fx.add(2, 3)); + print("scale", Fx.scale(2.0, 1.5)); + print("narrow", Fx.widen_s8(-5), Fx.widen_u16(65535), Fx.negate_s8(5), Fx.not(true), Fx.not(false)); + print("len", Fx.len("hello")); + print("greet", Fx.greet()); + + let quotient: s32 = 0; + let remainder: s32 = 0; + Fx.divmod(17, 5, ReferenceOf(quotient), ReferenceOf(remainder)); + print("divmod", quotient, remainder); + + const counter = Fx.counter_new(); + Fx.counter_inc(counter); + Fx.counter_inc(counter); + print("counter", Fx.counter_get(counter)); + Fx.counter_free(counter); + + let point: Fx.Point = [3, 4]; + print("point", Fx.point_sum(ReferenceOf(point))); + + let box: Fx.Box = [0, [0, 0], 0.0, false]; + Fx.box_fill(ReferenceOf(box)); + print("box", box[0], box[1][0], box[1][1], box[2], box[3]); + + let second: Fx.Node = [5, null]; + let first: Fx.Node = [3, ReferenceOf(second) as Opaque]; + print("list", Fx.list_sum(ReferenceOf(first))); + + print("color", Fx.color_value(Fx.Color.BLUE)); + print("apply", Fx.apply(twice as Opaque, 21)); + print("sum", Fx.sum(3, 1, 2, 3)); +} +``` + +`tslang/test/bindgen/expected.txt` — what both programs print, in both modes (the `narrow` line is Review Focus 1): + +```text +macros 42 1.5 fixture +add 5 +scale 3 +narrow -5 65535 -5 false true +len 5 +greet hello from C +divmod 3 2 +counter 2 +point 7 +box -7 30 -40 2.5 true +list 8 +color 6 +apply 42 +sum 6 +``` + +- [ ] **Step 2: Write the driver and register it** + +`tslang/test/bindgen/run-bindgen-test.cmake`: + +```cmake +# tsbindgen end to end: generate bindings for a real C library and call it through them. +# +# fixture.c is compiled with the in-tree clang, to an object and to a shared library; tsbindgen +# turns fixture.h into fixture.ts; bindgen_test.ts is compiled against it and linked with the +# object (--emit=exe), then run under the JIT against the shared library (--emit=jit). Both must +# print expected.txt. MODE=namespace does the same through `--namespace Fx --strip-prefix fx_`, +# which only links because @linkname binds each renamed function to its C symbol. + +cmake_minimum_required(VERSION 3.17.3) + +foreach(var MODE TSLANG TSBINDGEN CLANG SOURCE_DIR WORK_DIR GC_LIB LLVM_LIB TSLANG_LIB TSLANG_BIN OPT) + if(NOT DEFINED ${var}) + message(FATAL_ERROR "${var} is required") + endif() +endforeach() + +if(WIN32) + set(exe_suffix ".exe") + set(obj_suffix ".obj") + set(fixture_library "${WORK_DIR}/fixture_c.dll") + set(runtime "${TSLANG_BIN}/TypeScriptRuntime.dll") + set(pic "") + set(clang_pic "") + set(run_env "PATH=${WORK_DIR};$ENV{PATH}") +else() + set(exe_suffix "") + set(obj_suffix ".o") + set(fixture_library "${WORK_DIR}/libfixture_c.so") + set(runtime "${TSLANG_LIB}/libTypeScriptRuntime.so") + set(pic "-relocation-model=pic") + set(clang_pic "-fPIC") + set(run_env "LD_LIBRARY_PATH=${WORK_DIR}:$ENV{LD_LIBRARY_PATH}") +endif() + +# the C library's stem differs from the bindings' on purpose: `import "./fixture"` next to a +# fixture.dll would load that as a tslang library +if(MODE STREQUAL "namespace") + set(bindings "fixture_ns.ts") + set(program "bindgen_ns_test.ts") + set(bindgen_args --namespace Fx --strip-prefix fx_) +else() + set(bindings "fixture.ts") + set(program "bindgen_test.ts") + set(bindgen_args "") +endif() + +file(REMOVE_RECURSE "${WORK_DIR}") +file(MAKE_DIRECTORY "${WORK_DIR}") +file(COPY "${SOURCE_DIR}/fixture.h" "${SOURCE_DIR}/fixture.c" "${SOURCE_DIR}/${program}" DESTINATION "${WORK_DIR}") +file(READ "${SOURCE_DIR}/expected.txt" expected) +string(REPLACE "\r\n" "\n" expected "${expected}") + +set(common --no-default-lib ${OPT} ${pic} "--gc-lib-path=${GC_LIB}" "--llvm-lib-path=${LLVM_LIB}" + "--tslang-lib-path=${TSLANG_LIB}") + +# run( ) - fails unless it exits 0; leaves stdout in run_output +function(run what) + execute_process(COMMAND ${CMAKE_COMMAND} -E env "${run_env}" ${ARGN} + WORKING_DIRECTORY "${WORK_DIR}" + OUTPUT_VARIABLE out + ERROR_VARIABLE err + RESULT_VARIABLE status) + if(NOT status EQUAL 0) + message(FATAL_ERROR "${what}: exit ${status}\n${out}\n${err}") + endif() + string(REPLACE "\r\n" "\n" out "${out}") + set(run_output "${out}" PARENT_SCOPE) +endfunction() + +function(expect_output what) + if(NOT run_output STREQUAL expected) + message(FATAL_ERROR "${what} printed:\n${run_output}\nexpected:\n${expected}") + endif() + message(STATUS "${what}: as expected") +endfunction() + +run("clang -c fixture.c" "${CLANG}" -c ${clang_pic} fixture.c -o "fixture${obj_suffix}") +run("clang -shared fixture.c" "${CLANG}" -shared ${clang_pic} fixture.c -o "${fixture_library}") +run("tsbindgen" "${TSBINDGEN}" fixture.h ${bindgen_args} -o "${bindings}") + +run("--emit=exe" "${TSLANG}" --emit=exe ${common} "${program}" "--obj=fixture${obj_suffix}" -o "bindgen_test${exe_suffix}") +run("the program" "${WORK_DIR}/bindgen_test${exe_suffix}") +expect_output("--emit=exe") + +run("--emit=jit" "${TSLANG}" --emit=jit ${common} "--shared-libs=${runtime}" "--shared-libs=${fixture_library}" "${program}") +expect_output("--emit=jit") +``` + +In `tslang/test/tester/CMakeLists.txt`, immediately before the `add_test(NAME test-bindgen-cli ...)` from Task 4, with this comment heading both: + +```cmake +# tsbindgen: bindings generated from test/bindgen/fixture.h call the C library compiled from +# fixture.c, linked (--emit=exe) and under the JIT; `namespace` does it again through +# --namespace/--strip-prefix, which @linkname binds. And the command line's exit codes. +foreach(bindgen_mode plain namespace) + add_test(NAME test-bindgen-${bindgen_mode} + COMMAND ${CMAKE_COMMAND} + "-DMODE=${bindgen_mode}" + "-DTSLANG=$" + "-DTSBINDGEN=$" + "-DCLANG=${LLVM_TOOLS_BINARY_DIR}/clang${CMAKE_EXECUTABLE_SUFFIX}" + "-DSOURCE_DIR=${PROJECT_SOURCE_DIR}/test/bindgen" + "-DWORK_DIR=${CMAKE_CURRENT_BINARY_DIR}/bindgen-${bindgen_mode}" + "-DGC_LIB=${TEST_GC_LIBDIR}" + "-DLLVM_LIB=${defaultlib_collector_llvm_lib}" + "-DTSLANG_LIB=${CMAKE_BINARY_DIR}/lib" + "-DTSLANG_BIN=${CMAKE_BINARY_DIR}/bin" + "-DOPT=${defaultlib_collector_opt}" + -P "${PROJECT_SOURCE_DIR}/test/bindgen/run-bindgen-test.cmake") +endforeach() +``` + +- [ ] **Step 3: Run it to verify it fails** + +Run: `cmake . && ctest -C Release -R test-bindgen-plain --output-on-failure` +Expected: FAIL — `file COPY cannot find ".../tslang/test/bindgen/fixture.c"` (the C file is written in the next step). + +- [ ] **Step 4: Write the C library** + +`tslang/test/bindgen/fixture.c`: + +```c +#include "fixture.h" + +#include +#include + +struct Counter +{ + int32_t n; +}; + +int32_t fx_add(int32_t a, int32_t b) +{ + return a + b; +} + +double fx_scale(double v, float f) +{ + return v * f; +} + +int32_t fx_widen_s8(int8_t v) +{ + return v; +} + +int32_t fx_widen_u16(uint16_t v) +{ + return v; +} + +int8_t fx_negate_s8(int8_t v) +{ + return (int8_t)-v; +} + +bool fx_not(bool b) +{ + return !b; +} + +size_t fx_len(const char *s) +{ + size_t n = 0; + while (s[n]) + { + n++; + } + + return n; +} + +const char *fx_greet(void) +{ + return "hello from C"; +} + +void fx_divmod(int32_t a, int32_t b, int32_t *quotient, int32_t *remainder) +{ + *quotient = a / b; + *remainder = a % b; +} + +Counter *fx_counter_new(void) +{ + Counter *c = malloc(sizeof *c); + c->n = 0; + return c; +} + +void fx_counter_inc(Counter *c) +{ + c->n++; +} + +int32_t fx_counter_get(Counter *c) +{ + return c->n; +} + +void fx_counter_free(Counter *c) +{ + free(c); +} + +int32_t fx_point_sum(const Point *p) +{ + return p->x + p->y; +} + +void fx_box_fill(Box *b) +{ + b->tag = -7; + b->corner.x = 30; + b->corner.y = -40; + b->scale = 2.5; + b->visible = true; +} + +int32_t fx_list_sum(Node *head) +{ + int32_t sum = 0; + for (; head; head = head->next) + { + sum += head->value; + } + + return sum; +} + +int32_t fx_color_value(enum Color c) +{ + return (int32_t)c; +} + +int32_t fx_apply(int32_t (*fn)(int32_t), int32_t v) +{ + return fn(v); +} + +int32_t fx_sum(int32_t count, ...) +{ + va_list args; + va_start(args, count); + int32_t sum = 0; + for (int32_t i = 0; i < count; i++) + { + sum += va_arg(args, int32_t); + } + + va_end(args); + return sum; +} + +void fx_value_clear(union Value *v) +{ + v->i = 0; +} +``` + +- [ ] **Step 5: Run both modes** + +Run: `cmake --build . --config Release -j 16 --target tslang tsbindgen && ctest -C Release -R "test-bindgen-(plain|namespace)" -V` +Expected: both pass, each printing `-- --emit=exe: as expected` and `-- --emit=jit: as expected`. `test-bindgen-namespace` failing with `unresolved external symbol Fx.add` means PR 1 is not in this tree — see the Prerequisite. Failing with **only** `Fx.sum` unresolved means PR 1 mishandles `@varargs` and `@linkname` on the same declaration: a PR 1 bug to fix there, not a tsbindgen one. + +- [ ] **Step 6: Run the whole suite** + +Run: `ctest -j 16 -C Release` +Expected: `100% tests passed` — the previous total (2887 on 2026-09-26) plus 4 (`test-bindgen-plain`, `test-bindgen-namespace`, `test-bindgen-cli`, `unittest-TsClangImporterTests`). + +- [ ] **Step 7: Commit** + +```bash +git add tslang/test/bindgen tslang/test/tester/CMakeLists.txt +git commit -m "tsbindgen: end-to-end test against a C library, linked and under the JIT + +Co-Authored-By: Claude Opus 5.5 " +``` + +--- + +### Task 6: Linux + +Nothing in this plan was run on Linux. The code has no Windows-only path except `FIXTURE_API` and the `WIN32` branches of the two scripts, but Review Focus 1 (narrow integers on SysV) can only be checked there. + +**Files:** none, unless CI fails. + +- [ ] **Step 1: Push and open the PR as a draft; watch both CI workflows** + +```bash +gh auth switch --user ASDAlexander77 +git push +gh pr create --draft --title "tsbindgen v1: C headers to tslang bindings" --body-file <(printf 'Draft: CI check before review.\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n') +gh pr checks --watch +``` + +Expected: both `cmake-test-release-win` and `cmake-test-release-linux` green, with `test-bindgen-plain`, `test-bindgen-namespace`, `test-bindgen-cli` in the ctest log (`unittest-TsClangImporterTests` only if the runner has gtest's sources). + +- [ ] **Step 2: If a Linux bindgen test fails, classify before touching code** + +- `narrow` line differs (e.g. `narrow 251 65535 251 ...`): Review Focus 1 — tslang's call lowering. File a compiler issue with the failing line and the generated `fixture.ts`; mark the PR description with it; do not change the mapping. +- `clang: error: unknown ...` / `cannot find -l...`: the fixture build on Linux — fix `run-bindgen-test.cmake`'s non-`WIN32` branch. +- `tsbindgen` exit 1 on `fixture.h`: system headers not found — check the `LLVM_TARGZFILE` package's `lib/clang//include` (Task 1 Step 1). + +Rerun a flaky async test once before investigating it (see the CI flaky-test notes); these bindgen tests have no async code. + +--- + +### Task 7: Default-lib check (manual, reported in the PR) + +Spec "PR 2 tests" item 3, and Review Focus 3. + +**Files:** none. + +- [ ] **Step 1: Generate bindings for the two wrappers** + +```bash +B=I:/TypeScriptCompiler/__build/tslang/windows-msbuild-2026-release/bin +L=I:/TypeScriptCompilerDefaultLib/src +"$B/tsbindgen.exe" "$L/wrappers/regex.cpp" --filter "regexp_*" -o regex.ts +echo "regex exit=$?" +"$B/tsbindgen.exe" "$L/wrappers/http.cpp" --filter "http_*" -o http.ts +echo "http exit=$?" +``` + +Expected: both `exit=0`. If one fails on an include, pass the directories the default lib's own build uses with `-I` (see `TypeScriptCompilerDefaultLib`'s build scripts) and note them in the PR. + +- [ ] **Step 2: Compare with the hand-written declarations** + +```bash +grep -E "declare function (regexp|http)_" "$L/native/lib.native.d.ts" | sed 's/ */ /g' | sort > hand.txt +grep -hE "declare function (regexp|http)_" regex.ts http.ts | sed 's/ */ /g' | sort > generated.txt +diff hand.txt generated.txt +``` + +Expected differences, which are not bugs: `int`/`s32`, `double`/`f64`, `long`/`s64` or `s32`, `index` vs `u64`/`index`, and parameter names where the header has none. Any other difference is either a generator bug (fix it, with a unit test) or a mistake in `lib.native.d.ts` — write each one into the PR description saying which. + +--- + +### Task 8: Amend the spec; finish the PR + +**Files:** +- Modify: `docs/superpowers/specs/2026-09-25-tsbindgen-linkname-design.md` +- Modify: `tslang/docs/reference-counting-evaluation.md` (append a section) + +- [ ] **Step 1: Record the C-string open issue with the rc model's** + +The spec's open issue "C-owned `char*` returned as `string`" says to record it there. Append after the last `### 9.` section (9.77 on 2026-09-26) a section numbered one higher: + +```markdown +### 9.78 A C string returned through generated bindings (open) + +tsbindgen maps a C function's `char *` / `const char *` result to `string`, as the default library +already does by hand (`regexp_match_results_format`). A tslang string carries an 8-byte header before +its characters; a C-owned `char *` has none. Under `-mm=gc` that is harmless - nothing reads the +header. Under `-mm=rc` a release of such a value reads a count that is not there, which is undefined. +Not fixed: tsbindgen v1 targets `gc`. Revisit if rc becomes a supported model for bindings - the +likely shape is a distinct C-string type in the compiler that is never released. +``` + +- [ ] **Step 2: Amend the spec** + +In the spec: change the mapping table's integer row to `s8`…`s64` / `u8`…`u64`; change `-o ` to `-o ` (default stdout) and every "`.d.ts`" that names tsbindgen's output to "`.ts`"; replace the function-pointer row's comment form with the inline `/* ... */` one; and add, after "Skipped declarations", a subsection **"As built (2026-09-26)"** containing this plan's "Deviations from the spec" table verbatim. Change the Status line to "PR 2 implemented; PR 3 (packaging) not started." + +- [ ] **Step 3: Commit, then mark the PR ready** + +```bash +git add docs/superpowers/specs/2026-09-25-tsbindgen-linkname-design.md tslang/docs/reference-counting-evaluation.md +git commit -m "Spec: tsbindgen as built - .ts output, signed sN, type cycles + +Co-Authored-By: Claude Opus 5.5 " +git push +``` + +Write the PR description (via `gh api` — `gh pr edit` is broken on this repo): what tsbindgen does, the deviations table, Task 7's comparison, and any Linux finding from Task 6. End it with `🤖 Generated with [Claude Code](https://claude.com/claude-code)`. Then `gh pr ready`. From 94d0785aa0f9193c4df4677c078efdc494b0171a Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Sat, 26 Sep 2026 13:22:53 +0100 Subject: [PATCH 07/11] Accept @linkname as a second name for @dllname Co-Authored-By: Claude Opus 5.5 --- tslang/include/TypeScript/Defines.h | 1 + tslang/lib/TypeScript/MLIRGenFunctions.cpp | 5 ++ tslang/lib/TypeScript/MLIRGenImpl.h | 40 ++++++++++++++++ tslang/lib/TypeScript/MLIRGenVariables.cpp | 5 ++ tslang/test/tester/CMakeLists.txt | 25 ++++++++++ .../conflicting-decorators-variable.ts | 7 +++ .../tester/linkname/conflicting-decorators.ts | 7 +++ tslang/test/tester/tests/declare_linkname.ts | 8 ++++ tslang/test/tester/tests/export_linkname.ts | 47 +++++++++++++++++++ tslang/test/tester/tests/import_linkname.ts | 20 ++++++++ 10 files changed, 165 insertions(+) create mode 100644 tslang/test/tester/linkname/conflicting-decorators-variable.ts create mode 100644 tslang/test/tester/linkname/conflicting-decorators.ts create mode 100644 tslang/test/tester/tests/declare_linkname.ts create mode 100644 tslang/test/tester/tests/export_linkname.ts create mode 100644 tslang/test/tester/tests/import_linkname.ts diff --git a/tslang/include/TypeScript/Defines.h b/tslang/include/TypeScript/Defines.h index 50c89b2c4..c30c8a9b0 100644 --- a/tslang/include/TypeScript/Defines.h +++ b/tslang/include/TypeScript/Defines.h @@ -127,6 +127,7 @@ #define DLL_EXPORT "dllexport" #define DLL_IMPORT "dllimport" #define DLL_NAME "dllname" +#define LINK_NAME "linkname" #if __LP64__ #define TRAMPOLINE_SIZE 48 diff --git a/tslang/lib/TypeScript/MLIRGenFunctions.cpp b/tslang/lib/TypeScript/MLIRGenFunctions.cpp index a2231d5be..5d246f177 100644 --- a/tslang/lib/TypeScript/MLIRGenFunctions.cpp +++ b/tslang/lib/TypeScript/MLIRGenFunctions.cpp @@ -366,6 +366,11 @@ namespace mlirgen auto suppressExportForGenericInstantiation = functionLikeDeclarationBaseAST->typeParameters.size() > 0 && !genContext.typeParamsWithArgs.empty(); + if (mlir::failed(checkLinkNameDecorators(location, functionLikeDeclarationBaseAST, genContext))) + { + return std::make_tuple(funcOp, funcProto, mlir::failure(), false); + } + SmallVector attrs; auto dllExport = processFunctionAttributes(location, fullName, functionLikeDeclarationBaseAST, attrs, funcProtoGenContext, suppressExportForGenericInstantiation); diff --git a/tslang/lib/TypeScript/MLIRGenImpl.h b/tslang/lib/TypeScript/MLIRGenImpl.h index 720ce2807..5d969d124 100644 --- a/tslang/lib/TypeScript/MLIRGenImpl.h +++ b/tslang/lib/TypeScript/MLIRGenImpl.h @@ -11596,11 +11596,51 @@ class MLIRGenImpl if (expr == SyntaxKind::Identifier) { auto name = MLIRHelper::getName(expr.as(), stringAllocator); + // @linkname is @dllname under the name that fits a static link; every reader + // of DLL_NAME takes both + if (name == LINK_NAME) + { + name = DLL_NAME; + } + functor(name, args); } } } + // Two @dllname/@linkname decorators naming different symbols leave it ambiguous which one + // the declaration binds, so that is an error rather than last-one-wins. An error only + // reaches the user from a statement that fails (outputDiagnostics), so callers must + // return this result. + mlir::LogicalResult checkLinkNameDecorators(mlir::Location location, Node node, const GenContext &genContext) + { + StringRef first; + StringRef second; + iterateDecorators(node, genContext, [&](StringRef name, SmallVector args) { + if (name != DLL_NAME || args.empty()) + { + return; + } + + if (first.empty()) + { + first = args.front(); + } + else if (second.empty() && args.front() != first) + { + second = args.front(); + } + }); + + if (!second.empty()) + { + emitError(location) << "conflicting @dllname/@linkname: '" << first << "' vs '" << second << "'"; + return mlir::failure(); + } + + return mlir::success(); + } + bool isAddedToExport(mlir::Type type) { if (stage != Stages::SourceGeneration) diff --git a/tslang/lib/TypeScript/MLIRGenVariables.cpp b/tslang/lib/TypeScript/MLIRGenVariables.cpp index a12e6b25e..889c8831d 100644 --- a/tslang/lib/TypeScript/MLIRGenVariables.cpp +++ b/tslang/lib/TypeScript/MLIRGenVariables.cpp @@ -938,6 +938,11 @@ namespace mlirgen varClass.invariant = true; } }); + + if (mlir::failed(checkLinkNameDecorators(loc(variableDeclarationListAST), variableDeclarationListAST->parent, genContext))) + { + return mlir::failure(); + } } // An exported module-level const is a variable, as an exported `let` is. A const may be diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 0d0ae6a4a..7fb61152a 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -1286,6 +1286,9 @@ tslang_add_test(NAME test-compile-shared-export-import-function-generic COMMAND tslang_add_test(NAME test-compile-shared-export-import-type-alias-generic COMMAND test-runner -shared "${PROJECT_SOURCE_DIR}/test/tester/tests/import_type_alias_generic.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_type_alias_generic.ts") tslang_add_test(NAME test-compile-shared-export-import-function COMMAND test-runner -shared "${PROJECT_SOURCE_DIR}/test/tester/tests/import_function.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_function.ts") tslang_add_test(NAME test-compile-shared-export-import-dllname COMMAND test-runner -shared "${PROJECT_SOURCE_DIR}/test/tester/tests/import_dllname.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_dllname.ts") +tslang_add_test(NAME test-compile-shared-export-import-linkname COMMAND test-runner -shared "${PROJECT_SOURCE_DIR}/test/tester/tests/import_linkname.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_linkname.ts") +tslang_add_test(NAME test-compile-declare-linkname COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/declare_linkname.ts") +tslang_add_test(NAME test-jit-declare-linkname COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/declare_linkname.ts") tslang_add_test(NAME test-compile-shared-export-import-type-alias COMMAND test-runner -shared "${PROJECT_SOURCE_DIR}/test/tester/tests/import_type_alias.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_type_alias.ts") tslang_add_test(NAME test-compile-shared-export-import-interface-generic COMMAND test-runner -shared "${PROJECT_SOURCE_DIR}/test/tester/tests/import_interface_generic.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_interface_generic.ts") # FIXED (2026-07-22): the -shared declaration-reconstruction path never printed real @@ -2250,6 +2253,28 @@ foreach(call_arity_case ${call_arity_cases}) FAIL_REGULAR_EXPRESSION "Stack dump|Assertion failed") endforeach() +# @dllname and @linkname are one decorator under two names, and a symbol is bound once: each +# case is a program the compiler must reject with the given message instead of binding +# something else or making the name unique. +set(linkname_cases + "conflicting-decorators|conflicting @dllname/@linkname: 'strlen' vs 'wcslen'" + "conflicting-decorators-variable|conflicting @dllname/@linkname: 'linkname_var_a' vs 'linkname_var_b'") +foreach(linkname_case ${linkname_cases}) + string(FIND "${linkname_case}" "|" linkname_sep) + string(SUBSTRING "${linkname_case}" 0 ${linkname_sep} linkname_name) + math(EXPR linkname_sep "${linkname_sep} + 1") + string(SUBSTRING "${linkname_case}" ${linkname_sep} -1 linkname_message) + add_test(NAME test-compile-linkname-${linkname_name} + COMMAND $ --emit=obj --no-default-lib -mm=none + "${PROJECT_SOURCE_DIR}/test/tester/linkname/${linkname_name}.ts" + -o "${CMAKE_CURRENT_BINARY_DIR}/linkname-${linkname_name}.obj") + # PASS_REGULAR_EXPRESSION alone ignores the exit code, so a crash after printing the + # expected message would still pass; FAIL_REGULAR_EXPRESSION catches that. + set_tests_properties(test-compile-linkname-${linkname_name} + PROPERTIES PASS_REGULAR_EXPRESSION "${linkname_message}" + FAIL_REGULAR_EXPRESSION "Stack dump|Assertion failed") +endforeach() + # A statement's condition that produces no value is an error, not a statement generated # without its test (an `if` that vanished, an endless `for`, a segfault in `do`, an "empty # block" error from `while`). The void call diff --git a/tslang/test/tester/linkname/conflicting-decorators-variable.ts b/tslang/test/tester/linkname/conflicting-decorators-variable.ts new file mode 100644 index 000000000..2d2e69b62 --- /dev/null +++ b/tslang/test/tester/linkname/conflicting-decorators-variable.ts @@ -0,0 +1,7 @@ +@dllname("linkname_var_a") +@linkname("linkname_var_b") +export let x = 1; + +function main() { + print(x); +} diff --git a/tslang/test/tester/linkname/conflicting-decorators.ts b/tslang/test/tester/linkname/conflicting-decorators.ts new file mode 100644 index 000000000..64935529a --- /dev/null +++ b/tslang/test/tester/linkname/conflicting-decorators.ts @@ -0,0 +1,7 @@ +@dllname("strlen") +@linkname("wcslen") +declare function f(s: string): index; + +function main() { + print(f("a")); +} diff --git a/tslang/test/tester/tests/declare_linkname.ts b/tslang/test/tester/tests/declare_linkname.ts new file mode 100644 index 000000000..56b736235 --- /dev/null +++ b/tslang/test/tester/tests/declare_linkname.ts @@ -0,0 +1,8 @@ +// @linkname is @dllname: the TS name is cStrLen, the symbol it binds is the C runtime's strlen +@linkname("strlen") +declare function cStrLen(s: string): index; + +function main() { + assert(cStrLen("hello") == 5); + print("done."); +} diff --git a/tslang/test/tester/tests/export_linkname.ts b/tslang/test/tester/tests/export_linkname.ts new file mode 100644 index 000000000..d010e7600 --- /dev/null +++ b/tslang/test/tester/tests/export_linkname.ts @@ -0,0 +1,47 @@ +namespace L { + + // @linkname is @dllname under the name that fits a static link; it must rename every kind + // of symbol @dllname does, and survive the __decls round trip to the importer. + + @linkname("linkname_add") + export function add(a: number, b: number): number { + return a + b; + } + + export function addTwice(a: number, b: number): number { + return add(a, b) + add(a, b); + } + + @linkname("linkname_counter") + export let counter = 40; + + export function bump(): number { + counter = counter + 2; + return counter; + } + + export class Calc { + constructor(public factor: number) { + } + + @linkname("linkname_calc_twice") + static twice(x: number): number { + return x * 2; + } + + @linkname("linkname_calc_scale") + scale(x: number): number { + return x * this.factor; + } + + @linkname("linkname_calc_get_double") + get double(): number { + return this.factor * 2; + } + + @linkname("linkname_calc_set_double") + set double(value: number) { + this.factor = value / 2; + } + } +} diff --git a/tslang/test/tester/tests/import_linkname.ts b/tslang/test/tester/tests/import_linkname.ts new file mode 100644 index 000000000..4883ad2e8 --- /dev/null +++ b/tslang/test/tester/tests/import_linkname.ts @@ -0,0 +1,20 @@ +import './export_linkname' + +function main() { + assert(L.add(2, 3) == 5); + assert(L.addTwice(2, 3) == 10); + + assert(L.counter == 40); + assert(L.bump() == 42); + + assert(L.Calc.twice(4) == 8); + + const calc = new L.Calc(3); + assert(calc.scale(5) == 15); + + assert(calc.double == 6); + calc.double = 10; + assert(calc.factor == 5); + + print("done."); +} From 72c4a0b8d8bf69995f869d58043761a7f7dafa22 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Sat, 26 Sep 2026 13:26:39 +0100 Subject: [PATCH 08/11] Rename @dllname functions in MLIR, merging bindings of one symbol LLVM's setName made a taken name unique, so a second declaration of strlen called strlen.1 and linked to nothing. A function with @dllname/@linkname is now public: it defines a symbol that is bound by that name, and as a private function --opt inlined its callers and SymbolDCE erased it before the rename, leaving the declaration of that name with no definition. Co-Authored-By: Claude Opus 5.5 --- tslang/lib/TypeScript/LowerToLLVM.cpp | 101 ++++++++++++++++++ tslang/lib/TypeScript/MLIRGenFunctions.cpp | 7 +- .../TypeScriptExportPass/ExportFixPass.cpp | 2 + tslang/test/tester/CMakeLists.txt | 7 +- tslang/test/tester/linkname/not-a-function.ts | 8 ++ .../test/tester/linkname/two-definitions.ts | 13 +++ tslang/test/tester/linkname/type-mismatch.ts | 11 ++ .../tests/declare_linkname_shared_symbol.ts | 46 ++++++++ 8 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 tslang/test/tester/linkname/not-a-function.ts create mode 100644 tslang/test/tester/linkname/two-definitions.ts create mode 100644 tslang/test/tester/linkname/type-mismatch.ts create mode 100644 tslang/test/tester/tests/declare_linkname_shared_symbol.ts diff --git a/tslang/lib/TypeScript/LowerToLLVM.cpp b/tslang/lib/TypeScript/LowerToLLVM.cpp index 3b8ed0767..d99ba3648 100644 --- a/tslang/lib/TypeScript/LowerToLLVM.cpp +++ b/tslang/lib/TypeScript/LowerToLLVM.cpp @@ -7529,6 +7529,107 @@ void TypeScriptToLLVMLoweringPass::runOnOperation() SymbolTable::setSymbolName(globalOp, dllName); } + // @dllname/@linkname on a function: renamed here rather than in ExportFixPass, where LLVM's + // setName silently makes a taken name unique ("strlen.1") and the call binds nothing. + // Several TS declarations may bind one C symbol; the ones with the same TS type collapse into + // one op. The type is compared as the TS type, not the LLVM one: string and Opaque both lower + // to ptr, but the calls are still TS-typed here, and redirecting one to a callee of another + // signature would fail the verifier. + SmallVector renamedFuncs; + m.walk([&](mlir_ts::FuncOp funcOp) { + auto dllName = funcOp->getAttrOfType(DLL_NAME); + if (!dllName) + { + return; + } + + // naming the symbol the function already has is a no-op; dropped up front so the loop + // below never meets a still-attributed op under the target name + if (dllName == funcOp.getSymNameAttr()) + { + funcOp->removeAttr(DLL_NAME); + return; + } + + renamedFuncs.push_back(funcOp); + }); + + for (auto funcOp : renamedFuncs) + { + auto dllName = funcOp->getAttrOfType(DLL_NAME); + funcOp->removeAttr(DLL_NAME); + + auto existingSymbol = SymbolTable::lookupSymbolIn(m, dllName); + if (existingSymbol) + { + auto existingFunc = dyn_cast(existingSymbol); + if (!existingFunc) + { + funcOp.emitError("'") << funcOp.getSymName() << "' binds symbol '" << dllName.getValue() + << "', which is not a function"; + signalPassFailure(); + return; + } + + // an op still carrying the attribute is itself about to move away from this name + if (existingFunc->hasAttr(DLL_NAME)) + { + funcOp.emitError("'") << funcOp.getSymName() << "' binds symbol '" << dllName.getValue() + << "', which @dllname/@linkname renames to '" + << existingFunc->getAttrOfType(DLL_NAME).getValue() << "'"; + signalPassFailure(); + return; + } + + if (existingFunc.getFunctionType() != funcOp.getFunctionType()) + { + funcOp.emitError("'") << funcOp.getSymName() << "' binds symbol '" << dllName.getValue() + << "' with type " << funcOp.getFunctionType() << ", but '" + << existingFunc.getSymName() << "' declares it with type " + << existingFunc.getFunctionType(); + signalPassFailure(); + return; + } + + if (!funcOp.isExternal() && !existingFunc.isExternal()) + { + funcOp.emitError("'") << funcOp.getSymName() << "' and '" << existingFunc.getSymName() + << "' both define symbol '" << dllName.getValue() << "'"; + signalPassFailure(); + return; + } + + if (failed(SymbolTable::replaceAllSymbolUses(funcOp, dllName, m))) + { + funcOp.emitError("can't rename function to @dllname '") << dllName.getValue() << "'"; + signalPassFailure(); + return; + } + + // keep whichever of the two has the body + if (funcOp.isExternal()) + { + funcOp.erase(); + } + else + { + existingFunc.erase(); + SymbolTable::setSymbolName(funcOp, dllName); + } + + continue; + } + + if (failed(SymbolTable::replaceAllSymbolUses(funcOp, dllName, m))) + { + funcOp.emitError("can't rename function to @dllname '") << dllName.getValue() << "'"; + signalPassFailure(); + return; + } + + SymbolTable::setSymbolName(funcOp, dllName); + } + // The first thing to define is the conversion target. This will define the // final target for this lowering. For this lowering, we are only targeting // the LLVM dialect. diff --git a/tslang/lib/TypeScript/MLIRGenFunctions.cpp b/tslang/lib/TypeScript/MLIRGenFunctions.cpp index 5d246f177..ba806c1fd 100644 --- a/tslang/lib/TypeScript/MLIRGenFunctions.cpp +++ b/tslang/lib/TypeScript/MLIRGenFunctions.cpp @@ -924,7 +924,12 @@ namespace mlirgen isPublic |= ((functionLikeDeclarationBaseAST->internalFlags & InternalFlags::DllExport) == InternalFlags::DllExport) || ((functionLikeDeclarationBaseAST->internalFlags & InternalFlags::IsPublic) == InternalFlags::IsPublic) - || funcProto->getName() == MAIN_ENTRY_NAME; + || funcProto->getName() == MAIN_ENTRY_NAME + // @dllname/@linkname: the function defines a symbol that something else binds by that + // name - a `declare` in this module, or another object. Private, SymbolDCE may erase it + // before TypeScriptToLLVMLoweringPass gives it the name (--opt inlines its callers + // first), and the declaration then binds nothing. + || funcOp->hasAttr(DLL_NAME); // if explicit public/protected - set public visibility if (hasModifier(functionLikeDeclarationBaseAST, SyntaxKind::PublicKeyword) diff --git a/tslang/lib/TypeScriptExportPass/ExportFixPass.cpp b/tslang/lib/TypeScriptExportPass/ExportFixPass.cpp index da9bc2e17..c0e5e1896 100644 --- a/tslang/lib/TypeScriptExportPass/ExportFixPass.cpp +++ b/tslang/lib/TypeScriptExportPass/ExportFixPass.cpp @@ -26,6 +26,8 @@ struct ExportFixPassCode LLVM_DEBUG(llvm::dbgs() << "\nEXPORT Function: " << F.getName()); LLVM_DEBUG(llvm::dbgs() << "\nEXPORT Dump Before: ...\n" << F << "\n";); + // TypeScriptToLLVMLoweringPass renames @dllname/@linkname functions in MLIR, where a + // taken name can be merged or reported; this only catches an attribute added after it if (F.hasFnAttribute(DLL_NAME)) { auto dllName = F.getFnAttribute(DLL_NAME).getValueAsString().str(); diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 7fb61152a..1f9b5ba50 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -1289,6 +1289,8 @@ tslang_add_test(NAME test-compile-shared-export-import-dllname COMMAND test-runn tslang_add_test(NAME test-compile-shared-export-import-linkname COMMAND test-runner -shared "${PROJECT_SOURCE_DIR}/test/tester/tests/import_linkname.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_linkname.ts") tslang_add_test(NAME test-compile-declare-linkname COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/declare_linkname.ts") tslang_add_test(NAME test-jit-declare-linkname COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/declare_linkname.ts") +tslang_add_test(NAME test-compile-declare-linkname-shared-symbol COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/declare_linkname_shared_symbol.ts") +tslang_add_test(NAME test-jit-declare-linkname-shared-symbol COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/declare_linkname_shared_symbol.ts") tslang_add_test(NAME test-compile-shared-export-import-type-alias COMMAND test-runner -shared "${PROJECT_SOURCE_DIR}/test/tester/tests/import_type_alias.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_type_alias.ts") tslang_add_test(NAME test-compile-shared-export-import-interface-generic COMMAND test-runner -shared "${PROJECT_SOURCE_DIR}/test/tester/tests/import_interface_generic.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_interface_generic.ts") # FIXED (2026-07-22): the -shared declaration-reconstruction path never printed real @@ -2258,7 +2260,10 @@ endforeach() # something else or making the name unique. set(linkname_cases "conflicting-decorators|conflicting @dllname/@linkname: 'strlen' vs 'wcslen'" - "conflicting-decorators-variable|conflicting @dllname/@linkname: 'linkname_var_a' vs 'linkname_var_b'") + "conflicting-decorators-variable|conflicting @dllname/@linkname: 'linkname_var_a' vs 'linkname_var_b'" + "type-mismatch|binds symbol 'strlen' with type" + "two-definitions|both define symbol 'linkname_dup'" + "not-a-function|binds symbol 'linkname_counter', which is not a function") foreach(linkname_case ${linkname_cases}) string(FIND "${linkname_case}" "|" linkname_sep) string(SUBSTRING "${linkname_case}" 0 ${linkname_sep} linkname_name) diff --git a/tslang/test/tester/linkname/not-a-function.ts b/tslang/test/tester/linkname/not-a-function.ts new file mode 100644 index 000000000..789170ff5 --- /dev/null +++ b/tslang/test/tester/linkname/not-a-function.ts @@ -0,0 +1,8 @@ +let linkname_counter = 1; + +@linkname("linkname_counter") +declare function f(): i32; + +function main() { + print(linkname_counter, f()); +} diff --git a/tslang/test/tester/linkname/two-definitions.ts b/tslang/test/tester/linkname/two-definitions.ts new file mode 100644 index 000000000..ad8ba3438 --- /dev/null +++ b/tslang/test/tester/linkname/two-definitions.ts @@ -0,0 +1,13 @@ +@linkname("linkname_dup") +function a(): i32 { + return 1; +} + +@linkname("linkname_dup") +function b(): i32 { + return 2; +} + +function main() { + print(a(), b()); +} diff --git a/tslang/test/tester/linkname/type-mismatch.ts b/tslang/test/tester/linkname/type-mismatch.ts new file mode 100644 index 000000000..279c845cc --- /dev/null +++ b/tslang/test/tester/linkname/type-mismatch.ts @@ -0,0 +1,11 @@ +// string and Opaque both lower to ptr, but the calls are still TS-typed when the rename runs +@linkname("strlen") +declare function a(s: string): index; + +@linkname("strlen") +declare function b(s: Opaque): index; + +function main() { + let o: Opaque; + print(a("x"), b(o)); +} diff --git a/tslang/test/tester/tests/declare_linkname_shared_symbol.ts b/tslang/test/tester/tests/declare_linkname_shared_symbol.ts new file mode 100644 index 000000000..36b9c1080 --- /dev/null +++ b/tslang/test/tester/tests/declare_linkname_shared_symbol.ts @@ -0,0 +1,46 @@ +// Several declarations may bind one C symbol. Each used to reach LLVM as a function of its own, +// and LLVM's setName made the taken name unique ("strlen.1"), so the call bound nothing. + +declare function strlen(s: string): index; + +@linkname("strlen") +declare function cStrLen(s: string): index; + +@dllname("strlen") +declare function cStrLen2(s: string): index; + +// both spellings naming the same symbol is not a conflict +@dllname("strlen") +@linkname("strlen") +declare function cStrLen3(s: string): index; + +namespace C { + @linkname("strlen") + export declare function len(s: string): index; +} + +// a declaration bound to a function defined in this module +@linkname("linkname_twice") +function twice(x: i32): i32 { + return x * 2; +} + +declare function linkname_twice(x: i32): i32; + +// naming the symbol a function already has changes nothing +@linkname("same") +function same(): i32 { + return 7; +} + +function main() { + assert(strlen("a") == 1); + assert(cStrLen("ab") == 2); + assert(cStrLen2("abc") == 3); + assert(cStrLen3("abcd") == 4); + assert(C.len("abcde") == 5); + assert(twice(3) == 6); + assert(linkname_twice(4) == 8); + assert(same() == 7); + print("done."); +} From 766554af77c4c36e016be8bece193b9573ba0df0 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Sat, 26 Sep 2026 13:30:46 +0100 Subject: [PATCH 09/11] Keep dllexport off a declaration that has no body The cross-module test of an imported `export declare` binding is registered DISABLED: importing one crashes (null symbol lookup), as it did with @dllname before this branch. Co-Authored-By: Claude Opus 5.5 --- tslang/lib/TypeScript/MLIRGenImpl.h | 5 ++++- tslang/test/tester/CMakeLists.txt | 20 +++++++++++++++++++ tslang/test/tester/linkname/export-declare.ts | 8 ++++++++ .../tester/tests/export_declare_linkname.ts | 8 ++++++++ .../tester/tests/import_declare_linkname.ts | 6 ++++++ 5 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 tslang/test/tester/linkname/export-declare.ts create mode 100644 tslang/test/tester/tests/export_declare_linkname.ts create mode 100644 tslang/test/tester/tests/import_declare_linkname.ts diff --git a/tslang/lib/TypeScript/MLIRGenImpl.h b/tslang/lib/TypeScript/MLIRGenImpl.h index 5d969d124..a905096f6 100644 --- a/tslang/lib/TypeScript/MLIRGenImpl.h +++ b/tslang/lib/TypeScript/MLIRGenImpl.h @@ -2667,7 +2667,10 @@ class MLIRGenImpl auto dllExport = !suppressExport && (getExportModifier(functionLikeDeclarationBaseAST) || ((functionLikeDeclarationBaseAST->internalFlags & InternalFlags::DllExport) == InternalFlags::DllExport)); - if (dllExport) + // a declaration has no body to export: `export declare function` only makes it visible + // to importers, which the return value decides (__decls), while the attribute made the + // exe re-export the C function the declaration binds + if (dllExport && functionLikeDeclarationBaseAST->body) { attrs.push_back({mlir::StringAttr::get(builder.getContext(), "export"), mlir::UnitAttr::get(builder.getContext())}); } diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 1f9b5ba50..58c8b0580 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -1291,6 +1291,16 @@ tslang_add_test(NAME test-compile-declare-linkname COMMAND test-runner "${PROJEC tslang_add_test(NAME test-jit-declare-linkname COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/declare_linkname.ts") tslang_add_test(NAME test-compile-declare-linkname-shared-symbol COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/declare_linkname_shared_symbol.ts") tslang_add_test(NAME test-jit-declare-linkname-shared-symbol COMMAND test-runner -jit "${PROJECT_SOURCE_DIR}/test/tester/tests/declare_linkname_shared_symbol.ts") +# DISABLED: importing a module's `export declare function` bound to a C runtime function crashes, +# and did before @linkname (the same with @dllname on a 2026-09-23 build). The importer re-prints +# the declaration as `@dllimport @dllname("strlen")` and looks `strlen` up at startup in the loaded +# libraries (tslang_search_for_address_of_symbol); the exporting DLL never contains it - an unused +# declaration is dropped - so the lookup returns null and the call faults. Enable once that is fixed. +tslang_add_test(NAME test-compile-shared-export-import-declare-linkname COMMAND test-runner -shared "${PROJECT_SOURCE_DIR}/test/tester/tests/import_declare_linkname.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_declare_linkname.ts") +set_tests_properties(test-compile-shared-export-import-declare-linkname PROPERTIES DISABLED TRUE) +if (TSLANG_X86_TWIN) + set_tests_properties(${TSLANG_X86_TWIN} PROPERTIES DISABLED TRUE) +endif() tslang_add_test(NAME test-compile-shared-export-import-type-alias COMMAND test-runner -shared "${PROJECT_SOURCE_DIR}/test/tester/tests/import_type_alias.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_type_alias.ts") tslang_add_test(NAME test-compile-shared-export-import-interface-generic COMMAND test-runner -shared "${PROJECT_SOURCE_DIR}/test/tester/tests/import_interface_generic.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_interface_generic.ts") # FIXED (2026-07-22): the -shared declaration-reconstruction path never printed real @@ -2280,6 +2290,16 @@ foreach(linkname_case ${linkname_cases}) FAIL_REGULAR_EXPRESSION "Stack dump|Assertion failed") endforeach() +# `export declare function` makes a declaration visible to importers; it must not mark the C +# function it binds dllexport, which made an exe re-export the CRT's strlen. ExportFixPass +# adds dllexport only for MSVC targets, so off Windows this passes either way. +add_test(NAME test-compile-linkname-declare-not-exported + COMMAND $ --emit=llvm --no-default-lib -mm=none + "${PROJECT_SOURCE_DIR}/test/tester/linkname/export-declare.ts" -o -) +set_tests_properties(test-compile-linkname-declare-not-exported + PROPERTIES PASS_REGULAR_EXPRESSION "declare i64 @strlen\\(ptr\\)" + FAIL_REGULAR_EXPRESSION "declare dllexport|Stack dump|Assertion failed") + # A statement's condition that produces no value is an error, not a statement generated # without its test (an `if` that vanished, an endless `for`, a segfault in `do`, an "empty # block" error from `while`). The void call diff --git a/tslang/test/tester/linkname/export-declare.ts b/tslang/test/tester/linkname/export-declare.ts new file mode 100644 index 000000000..fd0ec526e --- /dev/null +++ b/tslang/test/tester/linkname/export-declare.ts @@ -0,0 +1,8 @@ +namespace C { + @linkname("strlen") + export declare function len(s: string): index; +} + +function main() { + print(C.len("hello")); +} diff --git a/tslang/test/tester/tests/export_declare_linkname.ts b/tslang/test/tester/tests/export_declare_linkname.ts new file mode 100644 index 000000000..d131d2227 --- /dev/null +++ b/tslang/test/tester/tests/export_declare_linkname.ts @@ -0,0 +1,8 @@ +namespace C { + + // `export declare` makes the binding visible to importers; it must not make this module + // re-export the C function it binds. + + @linkname("strlen") + export declare function len(s: string): index; +} diff --git a/tslang/test/tester/tests/import_declare_linkname.ts b/tslang/test/tester/tests/import_declare_linkname.ts new file mode 100644 index 000000000..ed5bb62b7 --- /dev/null +++ b/tslang/test/tester/tests/import_declare_linkname.ts @@ -0,0 +1,6 @@ +import './export_declare_linkname' + +function main() { + assert(C.len("hello") == 5); + print("done."); +} From 6c6b9a6c2e73e3bc95a5bfb490b90d6af04fd75a Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Sat, 26 Sep 2026 13:32:10 +0100 Subject: [PATCH 10/11] Note @linkname in the header-import design Co-Authored-By: Claude Opus 5.5 --- docs/c-cpp-header-import.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/c-cpp-header-import.md b/docs/c-cpp-header-import.md index 923a959ca..9a6dc6832 100644 --- a/docs/c-cpp-header-import.md +++ b/docs/c-cpp-header-import.md @@ -200,7 +200,7 @@ C is a 90%-mechanical translation; C++ is not. Realistic phasing: | Phase | Scope | Approach | Effort | | --- | --- | --- | --- | | C++ 0 | `extern "C"` blocks in C++ headers | Falls out of Option A for free (clang parses the header as C++, importer takes only C-linkage decls) | included in A | -| C++ 1 | Free functions, non-template classes with methods (no virtuals crossing the boundary), overloads | Bind by **mangled name**: clang provides Itanium/MSVC mangled symbols (`clang::MangleContext`); emit `declare` per overload with the mangled name attached (needs a small compiler addition: a `@linkname("?foo@@YAHH@Z")` attribute on declarations so the emitted `FuncOp` symbol differs from the TS-visible name). Methods become functions taking `this: Reference` first. Constructors/destructors bound explicitly; caller manages object memory (`GC_malloc_atomic` for PODs / explicit new-delete pair bindings). | 2–4 weeks after A | +| C++ 1 | Free functions, non-template classes with methods (no virtuals crossing the boundary), overloads | Bind by **mangled name**: clang provides Itanium/MSVC mangled symbols (`clang::MangleContext`); emit `declare` per overload with the mangled name attached (`@linkname("?foo@@YAHH@Z")`, an alias of `@dllname`, makes the emitted `FuncOp` symbol differ from the TS-visible name). Methods become functions taking `this: Reference` first. Constructors/destructors bound explicitly; caller manages object memory (`GC_malloc_atomic` for PODs / explicit new-delete pair bindings). | 2–4 weeks after A | | C++ 2 | Templates, virtual dispatch, exceptions, STL types | **Do not bind directly.** Generate a flattened `extern "C"` wrapper `.cpp` (tsbindgen emits both the wrapper and its `.d.ts`), compiled with the system C++ compiler and linked via existing `-obj=`. Explicit template instantiations only; exceptions caught at the wrapper boundary and converted to error codes (C++ exceptions must never unwind into tslang frames — different personality functions, especially in JIT where Win64 unwind info is custom, see `docs/jit-gc-static-roots.md` context). | 3–6 weeks, per-need | The C++ ABI reality (mangling differences, MSVC vs Itanium, RTTI, std::string From 5a00c0c0982875e44128569fac50cb0c92e5c7f9 Mon Sep 17 00:00:00 2001 From: ASDAlexander77 Date: Sat, 26 Sep 2026 13:46:53 +0100 Subject: [PATCH 11/11] Reject a rename to a vacated name in either order; test @linkname statically A function renamed to a name another @dllname/@linkname function is renamed away from was accepted or rejected depending on which came first in the file. Every vacated name is now recorded before the renames run. The static-link multi-file pairs for @linkname and for an imported `export declare` binding were passing but not registered. Co-Authored-By: Claude Opus 5.5 --- tslang/lib/TypeScript/LowerToLLVM.cpp | 24 ++++++++++++++---------- tslang/test/tester/CMakeLists.txt | 7 ++++++- tslang/test/tester/linkname/chain.ts | 16 ++++++++++++++++ 3 files changed, 36 insertions(+), 11 deletions(-) create mode 100644 tslang/test/tester/linkname/chain.ts diff --git a/tslang/lib/TypeScript/LowerToLLVM.cpp b/tslang/lib/TypeScript/LowerToLLVM.cpp index d99ba3648..bf6b4801a 100644 --- a/tslang/lib/TypeScript/LowerToLLVM.cpp +++ b/tslang/lib/TypeScript/LowerToLLVM.cpp @@ -7536,6 +7536,10 @@ void TypeScriptToLLVMLoweringPass::runOnOperation() // to ptr, but the calls are still TS-typed here, and redirecting one to a callee of another // signature would fail the verifier. SmallVector renamedFuncs; + // every name a rename moves a function away from, and where it goes: binding one of these + // would bind whichever function holds the name when the loop gets there, so it is an error + // whatever the order of the two declarations + llvm::DenseMap vacatedNames; m.walk([&](mlir_ts::FuncOp funcOp) { auto dllName = funcOp->getAttrOfType(DLL_NAME); if (!dllName) @@ -7552,6 +7556,7 @@ void TypeScriptToLLVMLoweringPass::runOnOperation() } renamedFuncs.push_back(funcOp); + vacatedNames[funcOp.getSymNameAttr()] = dllName; }); for (auto funcOp : renamedFuncs) @@ -7559,6 +7564,15 @@ void TypeScriptToLLVMLoweringPass::runOnOperation() auto dllName = funcOp->getAttrOfType(DLL_NAME); funcOp->removeAttr(DLL_NAME); + auto vacated = vacatedNames.find(dllName); + if (vacated != vacatedNames.end()) + { + funcOp.emitError("'") << funcOp.getSymName() << "' binds symbol '" << dllName.getValue() + << "', which @dllname/@linkname renames to '" << vacated->second.getValue() << "'"; + signalPassFailure(); + return; + } + auto existingSymbol = SymbolTable::lookupSymbolIn(m, dllName); if (existingSymbol) { @@ -7571,16 +7585,6 @@ void TypeScriptToLLVMLoweringPass::runOnOperation() return; } - // an op still carrying the attribute is itself about to move away from this name - if (existingFunc->hasAttr(DLL_NAME)) - { - funcOp.emitError("'") << funcOp.getSymName() << "' binds symbol '" << dllName.getValue() - << "', which @dllname/@linkname renames to '" - << existingFunc->getAttrOfType(DLL_NAME).getValue() << "'"; - signalPassFailure(); - return; - } - if (existingFunc.getFunctionType() != funcOp.getFunctionType()) { funcOp.emitError("'") << funcOp.getSymName() << "' binds symbol '" << dllName.getValue() diff --git a/tslang/test/tester/CMakeLists.txt b/tslang/test/tester/CMakeLists.txt index 58c8b0580..8ffbe0b38 100644 --- a/tslang/test/tester/CMakeLists.txt +++ b/tslang/test/tester/CMakeLists.txt @@ -1145,6 +1145,10 @@ tslang_add_test(NAME test-compile-import-component COMMAND test-runner "${PROJEC # in both fails to link. Only the program half is given --entry-point. tslang_add_test(NAME test-compile-export-import-root-var COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/import_root_var.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_root_var.ts") tslang_add_test(NAME test-compile-export-import-class-interface COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/import_class_interface.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_class_interface.ts") +# @linkname across a static link - the mode it is named for; the second is the live guard for an +# imported `export declare` binding while its -shared twin is DISABLED (see below) +tslang_add_test(NAME test-compile-export-import-linkname COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/import_linkname.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_linkname.ts") +tslang_add_test(NAME test-compile-export-import-declare-linkname COMMAND test-runner "${PROJECT_SOURCE_DIR}/test/tester/tests/import_declare_linkname.ts" "${PROJECT_SOURCE_DIR}/test/tester/tests/export_declare_linkname.ts") # Item 5al: an imported function's returned reference is taken over rather than retained # again. Registered statically as well as shared, and under every memory model - the # static two-module form had no rc/none coverage at all, which is why the leak it fixes @@ -2273,7 +2277,8 @@ set(linkname_cases "conflicting-decorators-variable|conflicting @dllname/@linkname: 'linkname_var_a' vs 'linkname_var_b'" "type-mismatch|binds symbol 'strlen' with type" "two-definitions|both define symbol 'linkname_dup'" - "not-a-function|binds symbol 'linkname_counter', which is not a function") + "not-a-function|binds symbol 'linkname_counter', which is not a function" + "chain|'a' binds symbol 'linkname_chain_b', which @dllname/@linkname renames to 'linkname_chain_c'") foreach(linkname_case ${linkname_cases}) string(FIND "${linkname_case}" "|" linkname_sep) string(SUBSTRING "${linkname_case}" 0 ${linkname_sep} linkname_name) diff --git a/tslang/test/tester/linkname/chain.ts b/tslang/test/tester/linkname/chain.ts new file mode 100644 index 000000000..074ce0875 --- /dev/null +++ b/tslang/test/tester/linkname/chain.ts @@ -0,0 +1,16 @@ +// A function renamed to a name another function is renamed away from: which one the call binds +// would depend on the order the renames run, so it is an error - in either textual order. This +// is the order that was accepted: linkname_chain_b is renamed first, and `a` then took its name. +@linkname("linkname_chain_c") +function linkname_chain_b(): i32 { + return 2; +} + +@linkname("linkname_chain_b") +function a(): i32 { + return 1; +} + +function main() { + print(a(), linkname_chain_b()); +}