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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,9 @@ justfile.local
.vscode-env
.lua-runtimes/

# Example projects: commit source (src/, vendor/, tsconfig.json, README.md),
# not generated Lua output.
examples/*/out/

# macOS-specific files
.DS_Store
2 changes: 1 addition & 1 deletion cmd/tslua/lualib.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ func runLualib() error {
overrideDir = "5.0"
}

bundle, err := lualib.BuildBundleFromSource(srcDir, langExtPath, luaTypesPath, luaTarget, overrideDir)
bundle, err := lualib.BuildBundleFromSource(srcDir, langExtPath, luaTypesPath, luaTarget, overrideDir, nil)
if err != nil {
return err
}
Expand Down
109 changes: 102 additions & 7 deletions cmd/tslua/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,12 @@ type buildConfig struct {
trace bool
noResolvePaths []string
stderrIsTerminal bool

// adapters is populated by transpile(); captured on the config so
// emitResults/writeResults can decide whether to rebuild lualib from
// source (when a user @lua*Runtime adapter is active) or use the
// embedded bundle.
adapters *transpiler.RuntimeAdapters
}

// transpileOpts returns the TranspileOptions derived from this build config.
Expand Down Expand Up @@ -241,10 +247,16 @@ func (cfg *buildConfig) transpileOpts() transpiler.TranspileOptions {
}

// transpile is the single transpilation chokepoint. Every entry point that
// produces Lua output calls this method. It wraps TranspileProgramWithOptions
// and centralizes validation (e.g. bundle+library mode conflict).
// produces Lua output calls this method. It scans for runtime adapters,
// wraps TranspileProgramWithOptions, and centralizes validation (e.g.
// bundle+library mode conflict).
func (cfg *buildConfig) transpile(program *compiler.Program, onlyFiles map[string]bool) ([]transpiler.TranspileResult, []*ast.Diagnostic) {
results, diags := transpiler.TranspileProgramWithOptions(program, cfg.sourceRoot, cfg.luaTarget, onlyFiles, cfg.transpileOpts())
adapters, adapterDiags := transpiler.ScanAdaptersFromProgram(program)
cfg.adapters = adapters
opts := cfg.transpileOpts()
opts.Adapters = adapters
results, diags := transpiler.TranspileProgramWithOptions(program, cfg.sourceRoot, cfg.luaTarget, onlyFiles, opts)
diags = append(adapterDiags, diags...)
if cfg.luaBundle != "" && cfg.buildMode == "library" {
diags = append(diags, dw.NewConfigError(dw.CannotBundleLibrary,
`Cannot bundle projects with "buildMode": "library". Projects including the library can still bundle (which will include external library files).`))
Expand Down Expand Up @@ -536,18 +548,33 @@ func writeBundle(cfg *buildConfig, results []transpiler.TranspileResult) error {
entryModule := transpiler.ModuleNameFromPath(string(entryPath), cfg.sourceRoot)

var lualibContent []byte
adapted := cfg.adapters.Any()
switch cfg.luaLibImport {
case transpiler.LuaLibImportRequire:
for _, r := range results {
if r.UsesLualib {
lualibContent = lualib.BundleForTarget(string(cfg.luaTarget))
if adapted {
content, err := buildAdaptedLualib(cfg)
if err != nil {
return fmt.Errorf("error building adapted lualib bundle: %w", err)
}
lualibContent = content
} else {
lualibContent = lualib.BundleForTarget(string(cfg.luaTarget))
}
break
}
}
case transpiler.LuaLibImportRequireMinimal:
usedExports := aggregateLualibExportsWithLuaFiles(results, cfg.sourceRoot)
if len(usedExports) > 0 {
content, err := lualib.MinimalBundleForTarget(string(cfg.luaTarget), usedExports)
var content []byte
var err error
if adapted {
content, err = buildAdaptedMinimalLualib(cfg, usedExports)
} else {
content, err = lualib.MinimalBundleForTarget(string(cfg.luaTarget), usedExports)
}
if err != nil {
return fmt.Errorf("error building minimal lualib bundle: %w", err)
}
Expand Down Expand Up @@ -576,6 +603,54 @@ func writeBundle(cfg *buildConfig, results []transpiler.TranspileResult) error {
return nil
}

// adaptedLualibSourcePaths returns the TSTL source locations used to rebuild
// the lualib bundle with active runtime adapters. Requires the tslua repo
// layout (extern/tstl, etc.) to be accessible via findRepoRoot.
func adaptedLualibSourcePaths(cfg *buildConfig) (srcDir, langExtPath, luaTypesPath, overrideDir string, err error) {
repoRoot, err := findRepoRoot()
if err != nil {
return "", "", "", "", fmt.Errorf("adapter lualib rebuild requires the tslua source tree: %w", err)
}
srcDir = filepath.Join(repoRoot, "extern", "tstl", "src", "lualib")
langExtPath = filepath.Join(repoRoot, "extern", "tstl", "language-extensions")
luaTypesPath = filepath.Join(repoRoot, "extern", "tstl", "node_modules", "lua-types")
overrideDir = "universal"
if cfg.luaTarget == transpiler.LuaTargetLua50 {
overrideDir = "5.0"
}
return srcDir, langExtPath, luaTypesPath, overrideDir, nil
}

// buildAdaptedLualib rebuilds the full lualib bundle from TSTL TypeScript
// source with the active runtime adapters applied.
func buildAdaptedLualib(cfg *buildConfig) ([]byte, error) {
srcDir, langExtPath, luaTypesPath, overrideDir, err := adaptedLualibSourcePaths(cfg)
if err != nil {
return nil, err
}
bundle, err := lualib.BuildBundleFromSource(srcDir, langExtPath, luaTypesPath, cfg.luaTarget, overrideDir, cfg.adapters)
if err != nil {
return nil, err
}
return []byte(bundle), nil
}

// buildAdaptedMinimalLualib rebuilds per-feature lualib data from TSTL source
// with active runtime adapters, then resolves a minimal bundle containing
// only usedExports plus transitive deps. Mirrors MinimalBundleForTarget but
// with adapter emitters propagated into lualib feature bodies.
func buildAdaptedMinimalLualib(cfg *buildConfig, usedExports []string) ([]byte, error) {
srcDir, langExtPath, luaTypesPath, overrideDir, err := adaptedLualibSourcePaths(cfg)
if err != nil {
return nil, err
}
data, err := lualib.BuildFeatureDataFromSource(srcDir, langExtPath, luaTypesPath, cfg.luaTarget, overrideDir, cfg.adapters)
if err != nil {
return nil, err
}
return []byte(data.ResolveMinimalBundle(usedExports)), nil
}

func writeResults(cfg *buildConfig, results []transpiler.TranspileResult) {
needsLualib := false
for _, r := range results {
Expand Down Expand Up @@ -613,13 +688,33 @@ func writeResults(cfg *buildConfig, results []transpiler.TranspileResult) {
}
if needsLualib {
var bundleContent []byte
// When a user @lua*Runtime adapter is active, the embedded lualib
// bundle (pre-built without adapter context) would leave internal
// reads like `arr.length` inside __TS__ArrayPush emitting raw `#arr`.
// Rebuild from source so the adapter propagates into lualib too.
adapted := cfg.adapters.Any()
switch cfg.luaLibImport {
case transpiler.LuaLibImportRequire:
bundleContent = lualib.BundleForTarget(string(cfg.luaTarget))
if adapted {
content, err := buildAdaptedLualib(cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "error building adapted lualib bundle: %v\n", err)
} else {
bundleContent = content
}
} else {
bundleContent = lualib.BundleForTarget(string(cfg.luaTarget))
}
case transpiler.LuaLibImportRequireMinimal:
usedExports := aggregateLualibExportsWithLuaFiles(results, cfg.sourceRoot)
if len(usedExports) > 0 {
content, err := lualib.MinimalBundleForTarget(string(cfg.luaTarget), usedExports)
var content []byte
var err error
if adapted {
content, err = buildAdaptedMinimalLualib(cfg, usedExports)
} else {
content, err = lualib.MinimalBundleForTarget(string(cfg.luaTarget), usedExports)
}
if err != nil {
fmt.Fprintf(os.Stderr, "error building minimal lualib bundle: %v\n", err)
} else {
Expand Down
95 changes: 95 additions & 0 deletions examples/array-length-adapter/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# array-length-adapter

Inspection example for the runtime-adapter kernel. A user-declared
`@luaArrayRuntime` primitive replaces `arr.length` emit with `Len(arr)` in
both user code and the lualib bundle.

Motivating scenario: an embedded Lua host whose arrays are proxy objects with
host-tracked length, where `#` returns raw table length and misses the proxy's
tracked length. The user supplies a `Len(arr)` free function; tslua routes
every `arr.length` emit through it.

## Layout

```
array-length-adapter/
tsconfig.json
src/main.ts # user code (unchanged from typical TS)
vendor/
runtime.d.ts # @luaArrayRuntime declaration + declare function Len
runtime.lua # stand-in Lua impl of Len (rawlen passthrough)
out/ # transpiled output
```

## Regenerate

From the repo root:

```
go build -o tslua ./cmd/tslua
./tslua -p examples/array-length-adapter/tsconfig.json
```

Produces `out/main.lua` (user code) and `out/lualib_bundle.lua` (lualib rebuilt
from TypeScript source with the adapter applied).

## What to look for

**User code**: every `arr.length` in `src/main.ts` emits `Len(arr)` instead of
`#items`.

**Lualib bundle**: internal reads of `arr.length` inside `__TS__ArrayPush`,
`__TS__ArrayFilter`, etc. also emit `Len(arr)`:

```lua
local function __TS__ArrayPush(self, ...)
local items = {...}
local len = Len(self)
for i = 1, Len(items) do
len = len + 1
self[len] = items[i]
end
return len
end
```

Non-adapter builds use the embedded pre-built bundle (zero cost). When an
adapter is active, the bundle is rebuilt from `extern/tstl/src/lualib/` on
every compile (~80 ms).

## Known gap: inline push fast-paths

The kernel wires `arr.length` reads but not the inline `arr[#arr + 1] = val`
idiom used by tslua's `push` single-arg fast path
(`internal/transpiler/builtins.go`) and a few TSTL lualib functions
(`Promise`, `ObjectGroupBy`, etc.). For a proxy-array host, these sites read
raw table length and write to the wrong slot. Grep the output for `#result`,
`#results`, `#pending`, `#rejections`, `#____` to see them. Follow-up: route
the push fast-path through the adapter, or add a dedicated push primitive.

## Running the output

The emitted `main.lua` requires `Len` to be in scope:

```
cd examples/array-length-adapter/out
lua -e 'dofile("../vendor/runtime.lua"); dofile("main.lua")'
```

## Declaration form

```typescript
// vendor/runtime.d.ts
declare function Len(arr: readonly unknown[]): number;

/** @luaArrayRuntime */
declare const HostArrayRuntime: {
length: typeof Len;
};
```

tslua scans for the `@luaArrayRuntime` JSDoc; the type literal lists the
primitives provided; `typeof Len` names the Lua identifier to emit. The
signature is validated at compile time (one array-typed param, number return);
a mismatch produces `RuntimeAdapterInvalidSignature` and leaves the default
emit in place.
25 changes: 25 additions & 0 deletions examples/array-length-adapter/src/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Exercise user-facing arr.length directly, and indirectly via methods
// that go through lualib (__TS__ArrayPush, __TS__ArrayFilter, etc.)
// which use arr.length internally. After step 4 (lualib rebuild), those
// internal reads will also emit Len(arr); today they still emit #arr
// because lualib comes from the embedded pre-built bundle.

declare function print(...args: unknown[]): void;

const items = [10, 20, 30];

// Direct length read: emits via adapter.
print("length via adapter:", items.length);

// Push uses __TS__ArrayPush internally; that function reads arr.length
// to find the next slot. Today the lualib bundle still emits #arr.
items.push(40, 50);
print("after push:", items.length);

// Filter via lualib: uses arr.length on both input and output arrays.
const evens = items.filter((x) => x % 2 === 0);
print("evens:", evens.length);

// Spread + ...
const copy = [...items];
print("copy:", copy.length);
14 changes: 14 additions & 0 deletions examples/array-length-adapter/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": ["esnext"],
"strict": true,
"moduleResolution": "bundler",
"rootDir": "src",
"outDir": "out"
},
"include": ["src/**/*", "vendor/runtime.d.ts"],
"tstl": {
"luaLibImport": "require-minimal"
}
}
11 changes: 11 additions & 0 deletions examples/array-length-adapter/vendor/runtime.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Runtime-adapter declaration for a hypothetical embedded host that
// exposes proxy arrays with host-tracked length via a free function `Len`.
// tslua reads the @luaArrayRuntime JSDoc tag and routes all `arr.length`
// emits through `Len(arr)` instead of the default `#arr`.

declare function Len(arr: readonly unknown[]): number;

/** @luaArrayRuntime */
declare const HostArrayRuntime: {
length: typeof Len;
};
9 changes: 9 additions & 0 deletions examples/array-length-adapter/vendor/runtime.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
-- Stand-in implementation for the host-provided Len primitive. In a real
-- embedded deployment this would be provided by the host (e.g. a C binding
-- to the host container's true length). Here we just shell out to rawlen
-- with a distinctive marker so it's easy to see in output.

function Len(arr)
-- PRINT-MARKER: if you see this message at runtime, Len was called.
return rawlen(arr)
end
23 changes: 16 additions & 7 deletions internal/lualib/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,13 @@ func wrapFileBody(body string, exports []string) string {

// transpileLualibSource transpiles TSTL's lualib TypeScript source files and
// returns the per-file results plus an export→file-index map.
func transpileLualibSource(lualibSrcDir, langExtPath, luaTypesPath string, luaTarget transpiler.LuaTarget, overrideDir string) ([]transpiler.TranspileResult, map[string]int, error) {
//
// When adapters is non-nil, the lualib transpile uses those emitters — so
// internal reads like `arr.length` inside `__TS__ArrayPush` route through the
// user-declared primitive (e.g. `Len(arr)`) rather than defaulting to `#arr`.
// A nil adapters value means "use tslua defaults" (the historical behavior
// used by the standalone `tslua lualib` CLI).
func transpileLualibSource(lualibSrcDir, langExtPath, luaTypesPath string, luaTarget transpiler.LuaTarget, overrideDir string, adapters *transpiler.RuntimeAdapters) ([]transpiler.TranspileResult, map[string]int, error) {
// Collect .ts files from the base directory
baseFiles, err := collectTSFiles(lualibSrcDir)
if err != nil {
Expand Down Expand Up @@ -203,6 +209,7 @@ func transpileLualibSource(lualibSrcDir, langExtPath, luaTypesPath string, luaTa
results, tsDiags := transpiler.TranspileProgramWithOptions(program, tmpDir, luaTarget, nil, transpiler.TranspileOptions{
ExportAsGlobal: true,
LuaLibImport: transpiler.LuaLibImportNone,
Adapters: adapters,
})
if len(tsDiags) > 0 {
var msgs []string
Expand Down Expand Up @@ -237,9 +244,10 @@ func transpileLualibSource(lualibSrcDir, langExtPath, luaTypesPath string, luaTa
}

// BuildBundleFromSource transpiles the TSTL lualib TypeScript source files
// and assembles them into a lualib_bundle.lua.
func BuildBundleFromSource(lualibSrcDir, langExtPath, luaTypesPath string, luaTarget transpiler.LuaTarget, overrideDir string) (string, error) {
results, exportToFile, err := transpileLualibSource(lualibSrcDir, langExtPath, luaTypesPath, luaTarget, overrideDir)
// and assembles them into a lualib_bundle.lua. Pass a non-nil adapters to
// apply user runtime-adapter emitters throughout the bundle.
func BuildBundleFromSource(lualibSrcDir, langExtPath, luaTypesPath string, luaTarget transpiler.LuaTarget, overrideDir string, adapters *transpiler.RuntimeAdapters) (string, error) {
results, exportToFile, err := transpileLualibSource(lualibSrcDir, langExtPath, luaTypesPath, luaTarget, overrideDir, adapters)
if err != nil {
return "", err
}
Expand Down Expand Up @@ -306,9 +314,10 @@ func BuildBundleFromSource(lualibSrcDir, langExtPath, luaTypesPath string, luaTa
}

// BuildFeatureDataFromSource transpiles the TSTL lualib TypeScript source files
// and returns per-feature metadata for selective inlining.
func BuildFeatureDataFromSource(lualibSrcDir, langExtPath, luaTypesPath string, luaTarget transpiler.LuaTarget, overrideDir string) (*FeatureData, error) {
results, exportToFile, err := transpileLualibSource(lualibSrcDir, langExtPath, luaTypesPath, luaTarget, overrideDir)
// and returns per-feature metadata for selective inlining. Pass a non-nil
// adapters to apply user runtime-adapter emitters to lualib feature bodies.
func BuildFeatureDataFromSource(lualibSrcDir, langExtPath, luaTypesPath string, luaTarget transpiler.LuaTarget, overrideDir string, adapters *transpiler.RuntimeAdapters) (*FeatureData, error) {
results, exportToFile, err := transpileLualibSource(lualibSrcDir, langExtPath, luaTypesPath, luaTarget, overrideDir, adapters)
if err != nil {
return nil, err
}
Expand Down
Loading