tjs-lang 0.7.8 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -223,6 +223,342 @@ export function extractWasmBlocks(source: string): {
223
223
  return { source: result, blocks }
224
224
  }
225
225
 
226
+ /**
227
+ * Extract top-level `wasm function NAME(params): RetType { body }` declarations.
228
+ *
229
+ * Unlike `extractWasmBlocks` (which finds `wasm { ... }` blocks nested inside
230
+ * regular tjs functions with auto-captured variables), this extractor finds
231
+ * top-level wasm function declarations with explicit parameters and an
232
+ * optional return type. The body is the wasm-subset source.
233
+ *
234
+ * Each declaration becomes a `WasmBlock` whose `captures` array holds the
235
+ * function's parameters with their type annotations (e.g. `['a: Float32Array',
236
+ * 'b: Float32Array', 'n: i32']`). The block ID is derived from the function
237
+ * name (`__tjs_wasm_<name>`), so the JS-side wrapper can reference it.
238
+ *
239
+ * The declaration is replaced in source with a regular JS function that
240
+ * forwards its args to the wasm export, preserving the `export` modifier
241
+ * if present:
242
+ *
243
+ * (export)? wasm function dot(a: Float32Array, b: Float32Array, n: f64): f64 {
244
+ * <wasm-source>
245
+ * }
246
+ *
247
+ * becomes:
248
+ *
249
+ * (export)? function dot(a, b, n) { return globalThis.__tjs_wasm_dot(a, b, n) }
250
+ *
251
+ * This runs BEFORE `extractWasmBlocks` so its output (a regular JS function
252
+ * wrapper) isn't disturbed by the inline-block scanner.
253
+ *
254
+ * Return-type note: the underlying wasm bytecode builder currently emits f64
255
+ * or void return types only. The declared `: RetType` annotation is parsed
256
+ * and preserved on the block, but not yet validated against the backend's
257
+ * capabilities — `: f64` and omitted-return work today; other types (`: i32`,
258
+ * `: f32`, `: v128`) will be supported when the bytecode builder grows
259
+ * per-function return-type encoding.
260
+ */
261
+ export function extractWasmFunctions(source: string): {
262
+ source: string
263
+ blocks: WasmBlock[]
264
+ } {
265
+ const blocks: WasmBlock[] = []
266
+ let result = ''
267
+ let i = 0
268
+
269
+ while (i < source.length) {
270
+ // Match: `(export )?wasm function NAME(` (with leading whitespace allowed)
271
+ // The leading `\b` ensures we don't match inside identifiers like `mywasm`.
272
+ const declRe = /^\b(export\s+)?wasm\s+function\s+(\w+)\s*\(/
273
+ const m = source.slice(i).match(declRe)
274
+ if (!m) {
275
+ result += source[i]
276
+ i++
277
+ continue
278
+ }
279
+
280
+ const hasExport = !!m[1]
281
+ const name = m[2]
282
+ const matchStart = i
283
+
284
+ // After the opening `(`: scan balanced parens for the params block
285
+ const parensStart = i + m[0].length
286
+ let parenDepth = 1
287
+ let j = parensStart
288
+ while (j < source.length && parenDepth > 0) {
289
+ if (source[j] === '(') parenDepth++
290
+ else if (source[j] === ')') parenDepth--
291
+ j++
292
+ }
293
+ if (parenDepth !== 0) {
294
+ // Unmatched parens — leave source alone and move on
295
+ result += source[i]
296
+ i++
297
+ continue
298
+ }
299
+ let paramsSource = source.slice(parensStart, j - 1)
300
+ // j now points just past the closing `)`
301
+
302
+ // Phase 2: detect `(!` — reserved syntax for unsafe wasm functions
303
+ // (will allow host-import calls, side-effecting globals, etc.).
304
+ // Implementation deferred; for now we reject with a clear message so
305
+ // users either remove the bang or know to wait.
306
+ const unsafeMatch = paramsSource.match(/^\s*!/)
307
+ if (unsafeMatch) {
308
+ throw new SyntaxError(
309
+ `Unsafe wasm functions (with \`!\` marker) are reserved for a ` +
310
+ `future phase. Remove the bang from \`wasm function ${name}\` ` +
311
+ `to declare it as a regular (pure) wasm function, or wait until ` +
312
+ `the unsafe variant is implemented.`,
313
+ locAt(source, matchStart)
314
+ )
315
+ }
316
+
317
+ // Optional return-type annotation: `: TYPE` (TYPE is a single identifier).
318
+ // Pointer-style annotations like `Ptr<f32>` are reserved for a follow-up.
319
+ let returnType: string | undefined
320
+ let afterReturnType = j
321
+ const retMatch = source.slice(j).match(/^\s*:\s*(\w+)/)
322
+ if (retMatch) {
323
+ returnType = retMatch[1]
324
+ afterReturnType = j + retMatch[0].length
325
+ }
326
+
327
+ // Expect `{` next (with leading whitespace)
328
+ const braceMatch = source.slice(afterReturnType).match(/^\s*\{/)
329
+ if (!braceMatch) {
330
+ // Not a wasm function decl after all — pass through
331
+ result += source[i]
332
+ i++
333
+ continue
334
+ }
335
+
336
+ // Find body via balanced braces
337
+ const bodyStart = afterReturnType + braceMatch[0].length
338
+ let braceDepth = 1
339
+ let k = bodyStart
340
+ while (k < source.length && braceDepth > 0) {
341
+ if (source[k] === '{') braceDepth++
342
+ else if (source[k] === '}') braceDepth--
343
+ k++
344
+ }
345
+ if (braceDepth !== 0) {
346
+ result += source[i]
347
+ i++
348
+ continue
349
+ }
350
+ const body = source.slice(bodyStart, k - 1)
351
+ // k now points just past the closing `}`
352
+
353
+ // Parse params into the existing `captures` shape used by the wasm
354
+ // compiler: each entry is either `name` or `name: type`.
355
+ const captures = parseWasmFunctionParams(paramsSource)
356
+
357
+ const id = `__tjs_wasm_${name}`
358
+ const block: WasmBlock = {
359
+ id,
360
+ name, // enables Phase 3 cross-file matching against imported symbols
361
+ returnType, // undefined when the user wrote no `: T` annotation
362
+ body,
363
+ captures,
364
+ start: matchStart,
365
+ end: k,
366
+ }
367
+ blocks.push(block)
368
+
369
+ // Generate the JS wrapper function. The wasm runtime sets globalThis[id]
370
+ // (with type-aware marshalling already baked into the wrapper); we just
371
+ // forward args. Preserve `export` if present.
372
+ const argNames = captures.map((c) => c.split(':')[0].trim())
373
+ const exportKeyword = hasExport ? 'export ' : ''
374
+ const wrapper = `${exportKeyword}function ${name}(${argNames.join(
375
+ ', '
376
+ )}) { return globalThis.${id}(${argNames.join(', ')}) }`
377
+
378
+ result += wrapper
379
+ i = k
380
+ }
381
+
382
+ return { source: result, blocks }
383
+ }
384
+
385
+ /**
386
+ * Phase 3: cross-file wasm-function composition.
387
+ *
388
+ * Scans the consumer's source for `import { name1, name2, ... } from 'spec'`
389
+ * statements. For each, resolves the spec via the supplied ModuleLoader; if
390
+ * the imported file is tjs/ts and one of the imported names corresponds to a
391
+ * `wasm function` declared there, the function's WasmBlock is pulled into the
392
+ * consumer's compilation and replaced in source by a local JS wrapper. The
393
+ * import statement is rewritten to remove the satisfied names (or removed
394
+ * entirely if every imported name was wasm-composed).
395
+ *
396
+ * This is the heart of the cross-file composition story: imported wasm
397
+ * functions become local functions in the consumer's single WebAssembly.Module
398
+ * (via the Phase 0.5 consolidated-module path). The library's transpiled .js
399
+ * is NOT involved — the source is consumed at transpile time.
400
+ *
401
+ * Caller is responsible for providing a configured ModuleLoader. When no
402
+ * loader is supplied (the common case before Phase 3 is fully wired up), this
403
+ * function returns the source unchanged with an empty blocks array.
404
+ *
405
+ * @param source the consumer's source (after extractWasmFunctions on its own
406
+ * wasm functions, before transformParenExpressions)
407
+ * @param options.loader the ModuleLoader to resolve imports through
408
+ * @param options.importerPath the path of the file being transpiled (used as
409
+ * the resolver's importer context); optional
410
+ * @returns updated source + the list of imported wasm function blocks
411
+ */
412
+ export function composeImportedWasmFunctions(
413
+ source: string,
414
+ options: {
415
+ loader?: {
416
+ load(specifier: string, importerPath?: string): {
417
+ parseResult: { wasmBlocks: WasmBlock[] }
418
+ } | null
419
+ }
420
+ importerPath?: string
421
+ }
422
+ ): { source: string; blocks: WasmBlock[] } {
423
+ const { loader, importerPath } = options
424
+ if (!loader) return { source, blocks: [] }
425
+
426
+ const composedBlocks: WasmBlock[] = []
427
+ // Track imported names that have been composed, so we don't pull the same
428
+ // wasm function in twice if it's imported from multiple specifiers.
429
+ const composedNames = new Set<string>()
430
+
431
+ /**
432
+ * Pull a wasm function into the consumer's composition and recursively
433
+ * pull in any other wasm functions it calls from the same source module.
434
+ *
435
+ * Transitive walking is required for correctness: if `outer` calls
436
+ * `inner` and the consumer only imports `outer`, we still need `inner`
437
+ * in the consumer's wasm module — otherwise outer's body's
438
+ * `call <inner-index>` instruction would reference a function index
439
+ * that doesn't exist. The wasm compiler would (correctly) reject the
440
+ * call at body-compile time.
441
+ *
442
+ * The body scan uses word-boundary regex against the known wasm-function
443
+ * names in the source module. False positives (matching a method call
444
+ * `obj.inner(x)` or a shadowed local) only cause bloat — not correctness
445
+ * problems — and TJS wasm bodies don't use either of those forms anyway.
446
+ */
447
+ function pullInTransitively(
448
+ block: WasmBlock,
449
+ sourceModuleFns: Map<string, WasmBlock>
450
+ ): void {
451
+ if (composedNames.has(block.id)) return
452
+ composedBlocks.push(block)
453
+ composedNames.add(block.id)
454
+ // Scan the body for calls to other wasm functions in this source module
455
+ for (const [name, target] of sourceModuleFns) {
456
+ if (name === block.name) continue // self isn't transitive
457
+ if (composedNames.has(target.id)) continue
458
+ const callRe = new RegExp(`\\b${name}\\s*\\(`)
459
+ if (callRe.test(block.body)) {
460
+ pullInTransitively(target, sourceModuleFns)
461
+ }
462
+ }
463
+ }
464
+
465
+ // Match `import { ... } from 'spec'` or `import { ... } from "spec"`.
466
+ // Default imports, namespace imports, and side-effect imports are left
467
+ // alone — only named-bindings are candidates for wasm composition.
468
+ // Multiline imports are supported via the [^}]*? non-greedy match.
469
+ const importRe = /^(\s*)import\s*\{([^}]*?)\}\s*from\s*(['"])([^'"]+)\3\s*;?\s*$/gm
470
+
471
+ const replaced = source.replace(
472
+ importRe,
473
+ (match, indent: string, bindings: string, _quote, spec: string) => {
474
+ const mod = loader.load(spec, importerPath)
475
+ if (!mod) return match // not a loadable tjs/ts module — leave as-is
476
+
477
+ const importedWasmFunctions = new Map<string, WasmBlock>()
478
+ for (const b of mod.parseResult.wasmBlocks) {
479
+ if (b.name) importedWasmFunctions.set(b.name, b)
480
+ }
481
+ if (importedWasmFunctions.size === 0) return match // no wasm here
482
+
483
+ // Parse the bindings list: `{ a, b as c, d }`
484
+ // Each binding is `name` or `name as local` (we keep `local` as the
485
+ // local name; for wasm-composed names, `local` must equal the wasm
486
+ // function name since the local wrapper uses the original name).
487
+ const parts = bindings
488
+ .split(',')
489
+ .map((p) => p.trim())
490
+ .filter((p) => p.length > 0)
491
+
492
+ const wrappers: string[] = []
493
+ const remainingBindings: string[] = []
494
+
495
+ for (const part of parts) {
496
+ const m = part.match(/^(\w+)(?:\s+as\s+(\w+))?$/)
497
+ if (!m) {
498
+ // Couldn't parse — be conservative and keep it as-is
499
+ remainingBindings.push(part)
500
+ continue
501
+ }
502
+ const imported = m[1]
503
+ const local = m[2] ?? m[1]
504
+ const wasmBlock = importedWasmFunctions.get(imported)
505
+ if (!wasmBlock) {
506
+ // Not a wasm function in the source module — keep the import
507
+ remainingBindings.push(part)
508
+ continue
509
+ }
510
+ // Composed: pull the block in (with any transitive callees from
511
+ // the same source module) and emit a local wrapper that uses the
512
+ // LOCAL name (in case of `as` renames) but forwards to the wasm
513
+ // export's original id. Transitive walking is what makes
514
+ // `import { outer } from 'lib'` work when outer internally calls
515
+ // inner — both end up in the consumer's composed module.
516
+ pullInTransitively(wasmBlock, importedWasmFunctions)
517
+ const argNames = wasmBlock.captures.map((c) =>
518
+ c.split(':')[0].trim()
519
+ )
520
+ wrappers.push(
521
+ `function ${local}(${argNames.join(
522
+ ', '
523
+ )}) { return globalThis.${wasmBlock.id}(${argNames.join(', ')}) }`
524
+ )
525
+ }
526
+
527
+ const wrapperBlock = wrappers.join('\n')
528
+
529
+ if (remainingBindings.length === 0) {
530
+ // All names were composed — drop the import statement entirely
531
+ return wrapperBlock ? `${indent}${wrapperBlock}` : `${indent}`
532
+ }
533
+ // Some names remain — keep them as a smaller import
534
+ const trimmedImport = `${indent}import { ${remainingBindings.join(
535
+ ', '
536
+ )} } from '${spec}'`
537
+ return wrapperBlock
538
+ ? `${trimmedImport}\n${indent}${wrapperBlock}`
539
+ : trimmedImport
540
+ }
541
+ )
542
+
543
+ return { source: replaced, blocks: composedBlocks }
544
+ }
545
+
546
+ /**
547
+ * Split a parameter list source into individual param strings.
548
+ * Returns entries like `name` (no type) or `name: type` (with type).
549
+ * Type values are single identifiers (`i32`, `Float32Array`, etc.) — generic
550
+ * forms like `Ptr<f32>` are reserved for a follow-up phase.
551
+ */
552
+ function parseWasmFunctionParams(paramsSource: string): string[] {
553
+ const trimmed = paramsSource.trim()
554
+ if (!trimmed) return []
555
+ // Simple comma split is fine for the current type syntax (no generics yet).
556
+ return trimmed
557
+ .split(',')
558
+ .map((s) => s.trim())
559
+ .filter((s) => s.length > 0)
560
+ }
561
+
226
562
  /** Check if an identifier is a WASM SIMD intrinsic (not a captured variable) */
227
563
  function isWasmIntrinsic(name: string): boolean {
228
564
  return name.startsWith('f32x4_') || name.startsWith('v128_')
@@ -15,6 +15,17 @@ export interface ParseOptions {
15
15
  * When true, skips == to Is() transformation since the VM handles == correctly.
16
16
  */
17
17
  vmTarget?: boolean
18
+ /**
19
+ * Optional ModuleLoader for cross-file `wasm function` composition (Phase 3).
20
+ * When provided, imports are resolved at transpile time and matching wasm
21
+ * functions are composed into the consumer's WebAssembly.Module. When
22
+ * omitted, imports are preserved verbatim (the default — runtime resolves
23
+ * them as before).
24
+ *
25
+ * Type is left as `any` here to avoid a circular import with module-loader.ts;
26
+ * callers should pass a `ModuleLoader` instance.
27
+ */
28
+ moduleLoader?: any
18
29
  }
19
30
 
20
31
  /**
@@ -37,6 +48,21 @@ export interface ParseOptions {
37
48
  export interface WasmBlock {
38
49
  /** Unique ID for this block */
39
50
  id: string
51
+ /**
52
+ * Declared function name (only set for top-level `wasm function NAME(...)`
53
+ * declarations — Phase 1+). Used by Phase 3 cross-file composition to
54
+ * match an imported symbol against a wasm function declaration. Inline
55
+ * `wasm {}` blocks have no name and don't participate in composition.
56
+ */
57
+ name?: string
58
+ /**
59
+ * Declared return-type annotation, e.g. `'f64'`. Only set for top-level
60
+ * `wasm function NAME(...): RetType` declarations; presence/absence is
61
+ * used to determine `hasReturn` BEFORE the body is compiled, so the
62
+ * function index map can be built up-front for wasm-to-wasm calls.
63
+ * Inline blocks have no declared return type.
64
+ */
65
+ returnType?: string
40
66
  /** The body (JS subset that compiles to WASM, also used as fallback) */
41
67
  body: string
42
68
  /** Explicit fallback body (only if different from body) */
@@ -83,6 +109,13 @@ export interface PreprocessOptions {
83
109
  * Default: false (transform == to Is() for TJS code running in regular JS)
84
110
  */
85
111
  vmTarget?: boolean
112
+ /**
113
+ * Optional ModuleLoader for cross-file `wasm function` composition (Phase 3).
114
+ * See ParseOptions.moduleLoader for details.
115
+ */
116
+ moduleLoader?: any
117
+ /** Path of the file being preprocessed (used as importer context). */
118
+ filename?: string
86
119
  }
87
120
 
88
121
  /**
@@ -31,6 +31,8 @@ import { transformParenExpressions } from './parser-params'
31
31
  import {
32
32
  transformTryWithoutCatch,
33
33
  extractWasmBlocks,
34
+ extractWasmFunctions,
35
+ composeImportedWasmFunctions,
34
36
  transformIsOperators,
35
37
  insertAsiProtection,
36
38
  transformEqualityToStructural,
@@ -262,6 +264,14 @@ export function preprocess(
262
264
  source = letAnnoResult.source
263
265
  const letAnnotations = letAnnoResult.annotations
264
266
 
267
+ // Extract `wasm function NAME(...) { ... }` declarations EARLY, before
268
+ // any source-level transforms that would mangle wasm-body text. In
269
+ // particular, the equality transforms below rewrite `==` to `Eq()` and
270
+ // `Is`/`IsNot` to function calls — wasm bodies use literal operators
271
+ // and shouldn't be affected.
272
+ const wasmFunctions = extractWasmFunctions(source)
273
+ source = wasmFunctions.source
274
+
265
275
  // Transform Is/IsNot infix operators to function calls
266
276
  // a Is b -> Is(a, b)
267
277
  // a IsNot b -> IsNot(a, b)
@@ -290,6 +300,18 @@ export function preprocess(
290
300
  // Foo = ... -> const Foo = ...
291
301
  source = transformBareAssignments(source)
292
302
 
303
+ // Phase 3: cross-file wasm-function composition. When a ModuleLoader is
304
+ // supplied, resolve `import { ... } from '<spec>'` statements at transpile
305
+ // time. Any imported names that correspond to `wasm function` declarations
306
+ // in the source module get pulled into the consumer's wasm module, with
307
+ // the import statement rewritten to a local JS wrapper. No loader supplied
308
+ // = no behavior change (imports stay verbatim, runtime resolves them).
309
+ const importedWasm = composeImportedWasmFunctions(source, {
310
+ loader: options.moduleLoader,
311
+ importerPath: options.filename,
312
+ })
313
+ source = importedWasm.source
314
+
293
315
  // Unified paren expression transformer
294
316
  // Handles: function params, arrow params, return types, safe/unsafe markers
295
317
  // Model: open paren can be ( or (? or (!, close can be ) or )-> or )-? or )-!
@@ -324,9 +346,24 @@ export function preprocess(
324
346
  source = polyResult.source
325
347
 
326
348
  // Extract WASM blocks: wasm(args) { ... } fallback { ... }
349
+ // `wasm function` declarations are already extracted earlier in the pipeline
350
+ // (see above, before transformParenExpressions). This finds the remaining
351
+ // inline `wasm { ... }` blocks inside regular tjs functions.
327
352
  const wasmBlocks = extractWasmBlocks(source)
328
353
  source = wasmBlocks.source
329
354
 
355
+ // Combine all flavors of wasm blocks for the downstream emitter.
356
+ // They're indistinguishable from the compiler's perspective — all have
357
+ // an id, body, captures, and need the same module composition treatment.
358
+ // - wasmFunctions: top-level `wasm function NAME(...)` decls in this file
359
+ // - importedWasm: cross-file `wasm function`s pulled in via Phase 3
360
+ // - wasmBlocks: inline `wasm { ... }` blocks nested in tjs functions
361
+ const allWasmBlocks = [
362
+ ...wasmFunctions.blocks,
363
+ ...importedWasm.blocks,
364
+ ...wasmBlocks.blocks,
365
+ ]
366
+
330
367
  // Extract and run test blocks: test 'desc'? { body }
331
368
  // Tests run at transpile time and are stripped from output
332
369
  const testResult = extractAndRunTests(source, options.dangerouslySkipTests)
@@ -381,7 +418,7 @@ export function preprocess(
381
418
  requiredParams,
382
419
  unsafeFunctions,
383
420
  safeFunctions,
384
- wasmBlocks: wasmBlocks.blocks,
421
+ wasmBlocks: allWasmBlocks,
385
422
  tests: testResult.tests,
386
423
  testErrors: testResult.errors,
387
424
  polymorphicNames: polyResult.polymorphicNames,
@@ -433,7 +470,11 @@ export function parse(
433
470
  letAnnotations,
434
471
  tjsModes,
435
472
  } = colonShorthand
436
- ? preprocess(source, { vmTarget })
473
+ ? preprocess(source, {
474
+ vmTarget,
475
+ moduleLoader: options.moduleLoader,
476
+ filename: options.filename,
477
+ })
437
478
  : {
438
479
  source,
439
480
  returnType: undefined,