diff --git a/.gitignore b/.gitignore index f6c2555..e8e4a85 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/cmd/tslua/lualib.go b/cmd/tslua/lualib.go index 57bbb40..c166c0b 100644 --- a/cmd/tslua/lualib.go +++ b/cmd/tslua/lualib.go @@ -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 } diff --git a/cmd/tslua/main.go b/cmd/tslua/main.go index 8d6bccc..9a8e59e 100644 --- a/cmd/tslua/main.go +++ b/cmd/tslua/main.go @@ -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. @@ -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).`)) @@ -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) } @@ -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 { @@ -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 { diff --git a/examples/array-length-adapter/README.md b/examples/array-length-adapter/README.md new file mode 100644 index 0000000..3d4a98e --- /dev/null +++ b/examples/array-length-adapter/README.md @@ -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. diff --git a/examples/array-length-adapter/src/main.ts b/examples/array-length-adapter/src/main.ts new file mode 100644 index 0000000..636d618 --- /dev/null +++ b/examples/array-length-adapter/src/main.ts @@ -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); diff --git a/examples/array-length-adapter/tsconfig.json b/examples/array-length-adapter/tsconfig.json new file mode 100644 index 0000000..f98ae9b --- /dev/null +++ b/examples/array-length-adapter/tsconfig.json @@ -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" + } +} diff --git a/examples/array-length-adapter/vendor/runtime.d.ts b/examples/array-length-adapter/vendor/runtime.d.ts new file mode 100644 index 0000000..265ad2d --- /dev/null +++ b/examples/array-length-adapter/vendor/runtime.d.ts @@ -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; +}; diff --git a/examples/array-length-adapter/vendor/runtime.lua b/examples/array-length-adapter/vendor/runtime.lua new file mode 100644 index 0000000..4c48916 --- /dev/null +++ b/examples/array-length-adapter/vendor/runtime.lua @@ -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 diff --git a/internal/lualib/build.go b/internal/lualib/build.go index a0a6426..4ecc345 100644 --- a/internal/lualib/build.go +++ b/internal/lualib/build.go @@ -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 { @@ -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 @@ -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 } @@ -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 } diff --git a/internal/lualib/build_test.go b/internal/lualib/build_test.go index 053fead..18c19a7 100644 --- a/internal/lualib/build_test.go +++ b/internal/lualib/build_test.go @@ -29,7 +29,7 @@ func TestCommittedBundleUpToDate(t *testing.T) { t.Skip("extern/tstl not set up (run `just tstl-setup`)") } - built, err := BuildBundleFromSource(srcDir, langExt, luaTypes, transpiler.LuaTargetUniversal, "universal") + built, err := BuildBundleFromSource(srcDir, langExt, luaTypes, transpiler.LuaTargetUniversal, "universal", nil) if err != nil { t.Fatalf("BuildBundleFromSource: %v", err) } @@ -93,7 +93,7 @@ func TestSelfBuiltBundleExportsStable(t *testing.T) { t.Skip("extern/tstl not set up (run `just tstl-setup`)") } - built, err := BuildBundleFromSource(srcDir, langExt, luaTypes, transpiler.LuaTargetUniversal, "universal") + built, err := BuildBundleFromSource(srcDir, langExt, luaTypes, transpiler.LuaTargetUniversal, "universal", nil) if err != nil { t.Fatalf("BuildBundleFromSource: %v", err) } @@ -194,7 +194,7 @@ func TestSelfBuiltBundleLeaksNoGlobals(t *testing.T) { t.Skip("extern/tstl not set up (run `just tstl-setup`)") } - built, err := BuildBundleFromSource(srcDir, langExt, luaTypes, transpiler.LuaTargetUniversal, "universal") + built, err := BuildBundleFromSource(srcDir, langExt, luaTypes, transpiler.LuaTargetUniversal, "universal", nil) if err != nil { t.Fatalf("BuildBundleFromSource: %v", err) } diff --git a/internal/luatest/lualib_transpile_test.go b/internal/luatest/lualib_transpile_test.go index 7ff4942..9bbfd81 100644 --- a/internal/luatest/lualib_transpile_test.go +++ b/internal/luatest/lualib_transpile_test.go @@ -39,7 +39,7 @@ func TestTranspile_LualibSmoke(t *testing.T) { langExtPath := filepath.Join(repoRoot, "extern", "tstl", "language-extensions") luaTypesPath := filepath.Join(repoRoot, "extern", "tstl", "node_modules", "lua-types") - bundle, err := lualib.BuildBundleFromSource(srcDir, langExtPath, luaTypesPath, transpiler.LuaTargetLua54, "universal") + bundle, err := lualib.BuildBundleFromSource(srcDir, langExtPath, luaTypesPath, transpiler.LuaTargetLua54, "universal", nil) if err != nil { t.Fatalf("build bundle: %v", err) } diff --git a/internal/transpiler/adapters.go b/internal/transpiler/adapters.go new file mode 100644 index 0000000..d84b5e5 --- /dev/null +++ b/internal/transpiler/adapters.go @@ -0,0 +1,329 @@ +package transpiler + +import ( + "fmt" + "strings" + + "github.com/microsoft/typescript-go/shim/ast" + "github.com/microsoft/typescript-go/shim/checker" + "github.com/microsoft/typescript-go/shim/compiler" + dw "github.com/microsoft/typescript-go/shim/diagnosticwriter" + "github.com/realcoldfry/tslua/internal/lua" +) + +// RuntimeAdapters holds per-category runtime adapter configuration for a +// program. Every adapter slot is always populated: the default emits native +// Lua operators (e.g. `#arr`) via Go AST construction; a user-provided +// @lua*Runtime declaration replaces the default with a call to a named Lua +// function. +// +// Kernel scope: only Array.length is wired. See notes/adapters/architecture.md. +type RuntimeAdapters struct { + Array ArrayAdapter + + // HasUserAdapter reports whether any user declaration overrode a default. + // The CLI uses this to decide whether to rebuild lualib from source. + HasUserAdapter bool +} + +// Any reports whether any user declaration overrode a default emitter. +// Callers use it to decide whether to rebuild lualib from source (cost: +// one full lualib pass) or use the embedded pre-built bundle (zero cost). +// Safe to call on a nil receiver: nil adapters means "no user adapter". +func (r *RuntimeAdapters) Any() bool { + return r != nil && r.HasUserAdapter +} + +// NewDefaultAdapters returns a RuntimeAdapters populated with tslua's built-in +// emitters. Every subsequent lookup on the returned value emits the native +// Lua operator or lualib call that tslua would emit without any adapter. +func NewDefaultAdapters() *RuntimeAdapters { + return &RuntimeAdapters{ + Array: ArrayAdapter{ + Length: defaultLengthEmitter{}, + }, + } +} + +// ArrayAdapter names the emitters for Array primitives. +type ArrayAdapter struct { + // Length emits the Lua expression for `arr.length` on array-typed + // expressions. Default: `#arr` (or `table.getn(arr)` for Lua 5.0). + Length LengthEmitter +} + +// LengthEmitter produces a Lua expression for an array length read. +// Implementations are branchless on the caller side: every emit site calls +// `t.adapters.Array.Length.Emit(t, arr)` and lets the emitter decide whether +// to emit a native operator or a function call. +type LengthEmitter interface { + Emit(t *Transpiler, arr lua.Expression) lua.Expression +} + +// defaultLengthEmitter emits tslua's default length operation: `#arr` on +// most targets, `table.getn(arr)` on Lua 5.0. +type defaultLengthEmitter struct{} + +func (defaultLengthEmitter) Emit(t *Transpiler, arr lua.Expression) lua.Expression { + return t.luaTarget.LenExpr(arr) +} + +// userLengthEmitter emits a call to a user-provided Lua function, +// e.g. `Len(arr)` for embedded hosts whose proxy arrays track length +// separately from Lua's raw table length. +type userLengthEmitter struct { + FnName string +} + +func (u userLengthEmitter) Emit(_ *Transpiler, arr lua.Expression) lua.Expression { + return lua.Call(lua.Ident(u.FnName), arr) +} + +// primitiveSignature describes the expected shape of an adapter function. +// The scanner uses this to validate that a user-declared function referenced +// by a @lua*Runtime const actually has the right signature before installing +// it as an adapter. On mismatch, the default emitter is retained and a +// diagnostic is emitted, preventing silent garbage at runtime. +type primitiveSignature struct { + // Category is a human-readable label used in diagnostics ("Array.length"). + Category string + // ParamCheck checks the type of the first argument. Returns "" on match, + // a reason string on mismatch (used verbatim in the diagnostic). + ParamCheck func(ch *checker.Checker, t *checker.Type) string + // ReturnCheck checks the return type. + ReturnCheck func(ch *checker.Checker, t *checker.Type) string +} + +var arrayLengthPrimitive = primitiveSignature{ + Category: "Array.length", + ParamCheck: expectArrayType, + ReturnCheck: expectNumberLike, +} + +// ScanAdaptersFromProgram is a convenience wrapper around ScanAdapters that +// obtains a checker from the program itself. Intended for callers (e.g. the +// CLI) that want the scanned adapters without managing checker lifecycle. +func ScanAdaptersFromProgram(program *compiler.Program) (*RuntimeAdapters, []*ast.Diagnostic) { + var ch *checker.Checker + compiler.Program_ForEachCheckerParallel(program, func(_ int, c *checker.Checker) { + ch = c + }) + return ScanAdapters(program, ch) +} + +// ScanAdapters inspects a program for @lua*Runtime adapter declarations and +// returns a populated RuntimeAdapters plus any diagnostics from signature +// validation. The result always contains working emitters: slots the user did +// not override, or whose user declaration failed validation, keep tslua's +// defaults. +// +// Expected declaration form: +// +// declare function Len(arr: readonly unknown[]): number; +// +// /** @luaArrayRuntime */ +// declare const MyArrayRuntime: { +// length: typeof Len; +// }; +// +// A ch of nil skips validation (used by callers that cannot supply a checker). +func ScanAdapters(program *compiler.Program, ch *checker.Checker) (*RuntimeAdapters, []*ast.Diagnostic) { + adapters := NewDefaultAdapters() + var diags []*ast.Diagnostic + for _, sf := range program.SourceFiles() { + if sf.Statements == nil { + continue + } + for _, stmt := range sf.Statements.Nodes { + if stmt.Kind != ast.KindVariableStatement { + continue + } + if !hasJSDocTag(stmt, sf, "luaarrayruntime") { + continue + } + diags = append(diags, applyArrayAdapter(stmt, sf, ch, adapters)...) + } + } + return adapters, diags +} + +// applyArrayAdapter reads the type literal of a @luaArrayRuntime-tagged +// `declare const` and installs the declared primitive emitters into +// adapters.Array. A declaration whose signature does not match the primitive's +// expected shape produces a diagnostic; the default emitter is retained for +// that slot so the transpile output is always valid Lua (just not adapted). +func applyArrayAdapter(varStmt *ast.Node, sf *ast.SourceFile, ch *checker.Checker, adapters *RuntimeAdapters) []*ast.Diagnostic { + vs := varStmt.AsVariableStatement() + if vs.DeclarationList == nil { + return nil + } + declList := vs.DeclarationList.AsVariableDeclarationList() + if declList.Declarations == nil || len(declList.Declarations.Nodes) == 0 { + return nil + } + decl := declList.Declarations.Nodes[0].AsVariableDeclaration() + if decl.Type == nil || !ast.IsTypeLiteralNode(decl.Type) { + return nil + } + lit := decl.Type.AsTypeLiteralNode() + if lit.Members == nil { + return nil + } + + var diags []*ast.Diagnostic + for _, member := range lit.Members.Nodes { + if !ast.IsPropertySignatureDeclaration(member) { + continue + } + ps := member.AsPropertySignatureDeclaration() + nameNode := ps.Name() + if nameNode == nil || nameNode.Kind != ast.KindIdentifier { + continue + } + if ps.Type == nil { + continue + } + fnName := typeQueryIdentifier(ps.Type) + if fnName == "" { + continue + } + switch nameNode.AsIdentifier().Text { + case "length": + if reason := validatePrimitive(ch, ps.Type, arrayLengthPrimitive); reason != "" { + diags = append(diags, dw.NewErrorForNode(sf, member, dw.RuntimeAdapterInvalidSignature, + fmt.Sprintf("@luaArrayRuntime primitive 'length' has invalid signature: %s", reason))) + continue + } + adapters.Array.Length = userLengthEmitter{FnName: fnName} + adapters.HasUserAdapter = true + } + } + return diags +} + +// validatePrimitive checks that the function referenced by a `typeof X` type +// query has a signature compatible with the expected primitive shape. +// Returns "" on match, or a reason string on mismatch. +// +// A nil checker skips validation (returns ""); callers without a checker +// trust the declaration. +func validatePrimitive(ch *checker.Checker, typeQueryNode *ast.Node, spec primitiveSignature) string { + if ch == nil { + return "" + } + tq := typeQueryNode.AsTypeQueryNode() + if tq.ExprName == nil { + return "missing function reference" + } + + typ := ch.GetTypeAtLocation(tq.ExprName) + if typ == nil { + return "unable to resolve type of " + tq.ExprName.AsIdentifier().Text + } + + sigs := checker.Checker_getSignaturesOfType(ch, typ, checker.SignatureKindCall) + if len(sigs) == 0 { + return "not a callable function" + } + sig := sigs[0] + params := checker.Signature_parameters(sig) + if len(params) < 1 { + return "expected at least 1 parameter" + } + paramType := checker.Checker_getTypeOfSymbol(ch, params[0]) + if reason := spec.ParamCheck(ch, paramType); reason != "" { + return "parameter 1: " + reason + } + retType := checker.Checker_getReturnTypeOfSignature(ch, sig) + if reason := spec.ReturnCheck(ch, retType); reason != "" { + return "return type: " + reason + } + return "" +} + +// expectArrayType reports "" when typ is an array, tuple, or has an array +// base, matching the shapes the transpiler already recognizes as array-like. +func expectArrayType(ch *checker.Checker, typ *checker.Type) string { + if typ == nil { + return "unresolved type" + } + stripped := checker.Checker_GetNonNullableType(ch, typ) + if isArrayLikeForAdapter(ch, stripped) { + return "" + } + return "expected an array type (T[] or readonly T[])" +} + +// expectNumberLike reports "" when typ is number, a numeric literal, or has +// a numeric base constraint. +func expectNumberLike(ch *checker.Checker, typ *checker.Type) string { + if typ == nil { + return "unresolved type" + } + stripped := checker.Checker_GetNonNullableType(ch, typ) + if checker.Type_flags(stripped)&checker.TypeFlagsNumberLike != 0 { + return "" + } + base := checker.Checker_getBaseConstraintOfType(ch, stripped) + if base != nil && checker.Type_flags(base)&checker.TypeFlagsNumberLike != 0 { + return "" + } + return "expected number" +} + +// isArrayLikeForAdapter is a scope-limited array check for scanner validation. +// It intentionally does not replicate the full isArrayTypeFromType lattice +// (generics, intersections, indexed access, etc.): adapter signatures should +// be stated in plain T[] / readonly T[] form. Exotic cases fall through to +// a diagnostic rather than silently passing. +func isArrayLikeForAdapter(ch *checker.Checker, typ *checker.Type) bool { + if checker.Type_flags(typ)&checker.TypeFlagsObject == 0 { + return false + } + return checker.Checker_isArrayOrTupleType(ch, typ) +} + +// hasJSDocTag reports whether a node carries a JSDoc @tagName (case-insensitive). +// Standalone variant of Transpiler.hasAnnotationTag, usable at program scan +// time before any Transpiler instance exists. +func hasJSDocTag(node *ast.Node, sf *ast.SourceFile, tagName string) bool { + if node.Flags&ast.NodeFlagsHasJSDoc == 0 { + return false + } + for _, jsDoc := range node.JSDoc(sf) { + if jsDoc.Kind != ast.KindJSDoc { + continue + } + tags := jsDoc.AsJSDoc().Tags + if tags == nil { + continue + } + for _, tag := range tags.Nodes { + if !ast.IsJSDocUnknownTag(tag) { + continue + } + name := tag.AsJSDocUnknownTag().TagName + if name == nil { + continue + } + if strings.ToLower(name.AsIdentifier().Text) == tagName { + return true + } + } + } + return false +} + +// typeQueryIdentifier returns the identifier from a `typeof X` type query. +// Returns "" for any other shape (qualified names like `typeof ns.X`, import +// types, unresolved references). +func typeQueryIdentifier(typeNode *ast.Node) string { + if typeNode.Kind != ast.KindTypeQuery { + return "" + } + tq := typeNode.AsTypeQueryNode() + if tq.ExprName == nil || tq.ExprName.Kind != ast.KindIdentifier { + return "" + } + return tq.ExprName.AsIdentifier().Text +} diff --git a/internal/transpiler/adapters_test.go b/internal/transpiler/adapters_test.go new file mode 100644 index 0000000..44cf2ad --- /dev/null +++ b/internal/transpiler/adapters_test.go @@ -0,0 +1,86 @@ +package transpiler + +import ( + "strings" + "testing" +) + +const adapterTsconfig = `{"compilerOptions":{"strict":true,"target":"ESNext","lib":["esnext"]}}` + +// Default (no @luaArrayRuntime): arr.length emits #arr. +func TestArrayLength_DefaultEmitsHashOperator(t *testing.T) { + code := ` +declare function print(...args: unknown[]): void; +const a = [1, 2, 3]; +print(a.length); +` + lua := transpileWithConfig(t, code, adapterTsconfig) + if !strings.Contains(lua, "#a") { + t.Errorf("expected emitted Lua to contain `#a` (default length emit); got:\n%s", lua) + } + if strings.Contains(lua, "Len(") { + t.Errorf("expected no `Len(` call in default emit; got:\n%s", lua) + } +} + +// User @luaArrayRuntime: arr.length emits Len(a) instead of #a. +func TestArrayLength_UserAdapterEmitsFunctionCall(t *testing.T) { + code := ` +declare function print(...args: unknown[]): void; +declare function Len(arr: readonly unknown[]): number; + +/** @luaArrayRuntime */ +declare const HostArrays: { + length: typeof Len; +}; + +const a = [1, 2, 3]; +print(a.length); +` + lua := transpileWithConfig(t, code, adapterTsconfig) + if !strings.Contains(lua, "Len(a)") { + t.Errorf("expected emitted Lua to contain `Len(a)`; got:\n%s", lua) + } + if strings.Contains(lua, "#a") { + t.Errorf("expected no `#a` in adapter emit; got:\n%s", lua) + } +} + +// An adapter declaration with a malformed property (non-typeof) leaves the +// default emit in place. Kernel-scope behavior: silently fall back to default. +// (Signature validation with explicit diagnostics lands in the next kernel step.) +func TestArrayLength_MalformedAdapterFallsBackToDefault(t *testing.T) { + code := ` +declare function print(...args: unknown[]): void; + +/** @luaArrayRuntime */ +declare const Broken: { + length: number; +}; + +const a = [1, 2, 3]; +print(a.length); +` + lua := transpileWithConfig(t, code, adapterTsconfig) + if !strings.Contains(lua, "#a") { + t.Errorf("expected fallback to `#a` on malformed adapter; got:\n%s", lua) + } +} + +// A @luaArrayRuntime declaration with no recognized primitives leaves all +// defaults in place (no user-adapter flag set). +func TestArrayLength_EmptyAdapterLeavesDefaults(t *testing.T) { + code := ` +declare function print(...args: unknown[]): void; + +/** @luaArrayRuntime */ +declare const Empty: {}; + +const a = [1, 2, 3]; +print(a.length); +` + lua := transpileWithConfig(t, code, adapterTsconfig) + if !strings.Contains(lua, "#a") { + t.Errorf("expected default `#a` for empty adapter; got:\n%s", lua) + } +} diff --git a/internal/transpiler/expressions.go b/internal/transpiler/expressions.go index 063dd49..da4227b 100644 --- a/internal/transpiler/expressions.go +++ b/internal/transpiler/expressions.go @@ -503,9 +503,11 @@ func (t *Transpiler) transformPropertyAccessExpression(node *ast.Node) lua.Expre obj := t.transformExpression(pa.Expression) - // .length on arrays → #obj (or table.getn for 5.0) + // .length on arrays: dispatched through the Array length adapter. + // Default emitter produces `#obj` (or `table.getn` for 5.0); a user + // @luaArrayRuntime declaration replaces it with `Len(obj)`. if prop == "length" && t.isArrayType(pa.Expression) { - return t.luaTarget.LenExpr(obj) + return t.adapters.Array.Length.Emit(t, obj) } return lua.Index(obj, lua.Str(prop)) diff --git a/internal/transpiler/transpiler.go b/internal/transpiler/transpiler.go index 193f9a5..64b978a 100644 --- a/internal/transpiler/transpiler.go +++ b/internal/transpiler/transpiler.go @@ -131,6 +131,7 @@ type Transpiler struct { classStyle ClassStyle // alternative class emit style (default: TSTL prototype chains) noResolvePaths map[string]bool // module specifiers to emit as-is without resolving (TSTL noResolvePaths) crossFileEnums map[string]bool // enum names declared in 2+ source files (need global scope for merging) + adapters *RuntimeAdapters // runtime adapters active for this program (nil = defaults) dependencies []ModuleDependency // module dependencies discovered during transformation // Scope stack & symbol tracking (replaces scopeDepth + hoistedFunctionsStack) @@ -428,6 +429,7 @@ type TranspileOptions struct { Trace bool // emit --[[trace: ...]] comments showing which TS node produced each Lua statement ClassStyle ClassStyle // alternative class emit style (default: TSTL prototype chains) NoResolvePaths []string // module specifiers to emit as-is without resolving (TSTL noResolvePaths) + Adapters *RuntimeAdapters // runtime adapters; nil triggers ScanAdapters on the program } // TranspileProgram transpiles all user source files in the program. @@ -484,6 +486,12 @@ func TranspileProgramWithOptions(program *compiler.Program, sourceRoot string, l } } + adapters := opts.Adapters + var adapterDiags []*ast.Diagnostic + if adapters == nil { + adapters, adapterDiags = ScanAdapters(program, ch) + } + var results []TranspileResult var diagnostics []*ast.Diagnostic for _, sf := range program.SourceFiles() { @@ -521,6 +529,7 @@ func TranspileProgramWithOptions(program *compiler.Program, sourceRoot string, l classStyle: opts.ClassStyle, noResolvePaths: noResolvePathsSet, crossFileEnums: crossFileEnums, + adapters: adapters, compilerOptions: program.Options(), isModule: isModule, isStrict: isModule, // ES modules are always strict; matches TSTL context.isStrict @@ -605,6 +614,7 @@ func TranspileProgramWithOptions(program *compiler.Program, sourceRoot string, l }) diagnostics = append(diagnostics, t.diagnostics...) } + diagnostics = append(diagnostics, adapterDiags...) return results, diagnostics } diff --git a/scripts/gen-lualib-features/main.go b/scripts/gen-lualib-features/main.go index e7284d7..943bfce 100644 --- a/scripts/gen-lualib-features/main.go +++ b/scripts/gen-lualib-features/main.go @@ -41,7 +41,7 @@ func main() { overrideDir = "universal" } - data, err := lualib.BuildFeatureDataFromSource(srcDir, langExtPath, luaTypesPath, luaTarget, overrideDir) + data, err := lualib.BuildFeatureDataFromSource(srcDir, langExtPath, luaTypesPath, luaTarget, overrideDir, nil) if err != nil { fatal(err) } diff --git a/shim/diagnosticwriter/shim.go b/shim/diagnosticwriter/shim.go index 2a3c3f9..a61811e 100644 --- a/shim/diagnosticwriter/shim.go +++ b/shim/diagnosticwriter/shim.go @@ -229,6 +229,8 @@ const ( CannotAssignToNodeOfKind DiagCode = 100046 IncompleteFieldDecoratorWarning DiagCode = 100047 UnsupportedArrayWithLengthConstructor DiagCode = 100048 + // + RuntimeAdapterInvalidSignature DiagCode = 101001 ) // diagHelp maps error codes to help text shown below the diagnostic.