tjs-lang 0.7.7 → 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.
- package/CLAUDE.md +99 -33
- package/bin/docs.js +4 -1
- package/demo/docs.json +104 -22
- package/demo/src/examples.test.ts +1 -0
- package/demo/src/imports.test.ts +16 -4
- package/demo/src/imports.ts +60 -15
- package/demo/src/playground-shared.ts +9 -8
- package/demo/src/tfs-worker.js +205 -147
- package/demo/src/tjs-playground.ts +34 -10
- package/demo/src/ts-examples.ts +8 -8
- package/demo/src/ts-playground.ts +24 -8
- package/dist/index.js +118 -101
- package/dist/index.js.map +4 -4
- package/dist/src/lang/bool-coercion.d.ts +50 -0
- package/dist/src/lang/docs.d.ts +31 -6
- package/dist/src/lang/linter.d.ts +8 -0
- package/dist/src/lang/parser-transforms.d.ts +18 -0
- package/dist/src/lang/parser-types.d.ts +2 -0
- package/dist/src/lang/parser.d.ts +3 -0
- package/dist/src/lang/runtime.d.ts +34 -0
- package/dist/src/lang/types.d.ts +9 -1
- package/dist/src/rbac/index.d.ts +1 -1
- package/dist/src/vm/runtime.d.ts +1 -1
- package/dist/tjs-eval.js +38 -36
- package/dist/tjs-eval.js.map +4 -4
- package/dist/tjs-from-ts.js +20 -20
- package/dist/tjs-from-ts.js.map +3 -3
- package/dist/tjs-lang.js +85 -83
- package/dist/tjs-lang.js.map +4 -4
- package/dist/tjs-vm.js +47 -45
- package/dist/tjs-vm.js.map +4 -4
- package/llms.txt +79 -0
- package/package.json +9 -4
- package/src/cli/commands/convert.test.ts +16 -21
- package/src/lang/bool-coercion.test.ts +203 -0
- package/src/lang/bool-coercion.ts +314 -0
- package/src/lang/codegen.test.ts +137 -0
- package/src/lang/docs.test.ts +476 -1
- package/src/lang/docs.ts +471 -37
- package/src/lang/emitters/ast.ts +11 -12
- package/src/lang/emitters/dts.test.ts +41 -0
- package/src/lang/emitters/dts.ts +9 -0
- package/src/lang/emitters/js-tests.ts +9 -4
- package/src/lang/emitters/js-wasm.ts +57 -65
- package/src/lang/emitters/js.ts +198 -3
- package/src/lang/features.test.ts +4 -3
- package/src/lang/index.ts +9 -0
- package/src/lang/inference.ts +54 -0
- package/src/lang/linter.test.ts +104 -1
- package/src/lang/linter.ts +124 -1
- package/src/lang/module-loader.test.ts +318 -0
- package/src/lang/module-loader.ts +419 -0
- package/src/lang/parser-params.ts +31 -0
- package/src/lang/parser-transforms.ts +640 -0
- package/src/lang/parser-types.ts +35 -0
- package/src/lang/parser.test.ts +73 -1
- package/src/lang/parser.ts +77 -3
- package/src/lang/runtime.ts +98 -0
- package/src/lang/types.ts +6 -0
- package/src/lang/wasm.test.ts +1293 -2
- package/src/lang/wasm.ts +470 -87
- package/src/linalg/index.tjs +119 -0
- package/src/linalg/linalg.test.ts +294 -0
- package/src/linalg/vector-search.bench.test.ts +395 -0
- package/src/rbac/index.ts +2 -2
- package/src/rbac/rules.tjs.d.ts +9 -0
- package/src/vm/atoms/batteries.ts +2 -2
- package/src/vm/runtime.ts +10 -3
- package/dist/src/rbac/rules.d.ts +0 -184
- package/src/rbac/rules.js +0 -338
|
@@ -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_')
|
|
@@ -3595,3 +3931,307 @@ function findMatchingOpen(
|
|
|
3595
3931
|
}
|
|
3596
3932
|
return pos
|
|
3597
3933
|
}
|
|
3934
|
+
|
|
3935
|
+
/**
|
|
3936
|
+
* Transform `let x: <example>` and `let x: <example> = value` declarations.
|
|
3937
|
+
*
|
|
3938
|
+
* Strips the `: <example>` annotation so Acorn can parse, and records the
|
|
3939
|
+
* variable name + example text so the linter and (later) type inference can
|
|
3940
|
+
* use the annotation. Acorn rejects the colon since it is not valid JS.
|
|
3941
|
+
*
|
|
3942
|
+
* let x: '' → let x (annotation: x → '')
|
|
3943
|
+
* let x: 0 = 5 → let x = 5 (annotation: x → 0)
|
|
3944
|
+
* let result: { ok: false } = ... (annotation: result → { ok: false })
|
|
3945
|
+
*
|
|
3946
|
+
* Only `let` is processed. `const` always has an initializer, so the type
|
|
3947
|
+
* is always inferable. `var` is rejected by TjsNoVar mode.
|
|
3948
|
+
*/
|
|
3949
|
+
export function transformLetTypeAnnotations(source: string): {
|
|
3950
|
+
source: string
|
|
3951
|
+
annotations: Map<string, string>
|
|
3952
|
+
} {
|
|
3953
|
+
const annotations = new Map<string, string>()
|
|
3954
|
+
if (!source.includes('let ')) return { source, annotations }
|
|
3955
|
+
|
|
3956
|
+
type Replacement = { start: number; end: number; replacement: string }
|
|
3957
|
+
const replacements: Replacement[] = []
|
|
3958
|
+
|
|
3959
|
+
let i = 0
|
|
3960
|
+
let state: TokenizerState = 'normal'
|
|
3961
|
+
const templateStack: number[] = []
|
|
3962
|
+
|
|
3963
|
+
while (i < source.length) {
|
|
3964
|
+
const char = source[i]
|
|
3965
|
+
const nextChar = source[i + 1]
|
|
3966
|
+
|
|
3967
|
+
switch (state) {
|
|
3968
|
+
case 'single-string':
|
|
3969
|
+
if (char === '\\' && i + 1 < source.length) {
|
|
3970
|
+
i += 2
|
|
3971
|
+
continue
|
|
3972
|
+
}
|
|
3973
|
+
if (char === "'") state = 'normal'
|
|
3974
|
+
i++
|
|
3975
|
+
continue
|
|
3976
|
+
case 'double-string':
|
|
3977
|
+
if (char === '\\' && i + 1 < source.length) {
|
|
3978
|
+
i += 2
|
|
3979
|
+
continue
|
|
3980
|
+
}
|
|
3981
|
+
if (char === '"') state = 'normal'
|
|
3982
|
+
i++
|
|
3983
|
+
continue
|
|
3984
|
+
case 'template-string':
|
|
3985
|
+
if (char === '\\' && i + 1 < source.length) {
|
|
3986
|
+
i += 2
|
|
3987
|
+
continue
|
|
3988
|
+
}
|
|
3989
|
+
if (char === '$' && nextChar === '{') {
|
|
3990
|
+
i += 2
|
|
3991
|
+
templateStack.push(1)
|
|
3992
|
+
state = 'normal'
|
|
3993
|
+
continue
|
|
3994
|
+
}
|
|
3995
|
+
if (char === '`') state = 'normal'
|
|
3996
|
+
i++
|
|
3997
|
+
continue
|
|
3998
|
+
case 'line-comment':
|
|
3999
|
+
if (char === '\n') state = 'normal'
|
|
4000
|
+
i++
|
|
4001
|
+
continue
|
|
4002
|
+
case 'block-comment':
|
|
4003
|
+
if (char === '*' && nextChar === '/') {
|
|
4004
|
+
i += 2
|
|
4005
|
+
state = 'normal'
|
|
4006
|
+
continue
|
|
4007
|
+
}
|
|
4008
|
+
i++
|
|
4009
|
+
continue
|
|
4010
|
+
case 'regex':
|
|
4011
|
+
if (char === '\\' && i + 1 < source.length) {
|
|
4012
|
+
i += 2
|
|
4013
|
+
continue
|
|
4014
|
+
}
|
|
4015
|
+
if (char === '[') {
|
|
4016
|
+
i++
|
|
4017
|
+
while (i < source.length && source[i] !== ']') {
|
|
4018
|
+
if (source[i] === '\\' && i + 1 < source.length) i += 2
|
|
4019
|
+
else i++
|
|
4020
|
+
}
|
|
4021
|
+
if (i < source.length) i++
|
|
4022
|
+
continue
|
|
4023
|
+
}
|
|
4024
|
+
if (char === '/') {
|
|
4025
|
+
i++
|
|
4026
|
+
while (i < source.length && /[gimsuy]/.test(source[i])) i++
|
|
4027
|
+
state = 'normal'
|
|
4028
|
+
continue
|
|
4029
|
+
}
|
|
4030
|
+
i++
|
|
4031
|
+
continue
|
|
4032
|
+
case 'normal':
|
|
4033
|
+
if (templateStack.length > 0) {
|
|
4034
|
+
if (char === '{') {
|
|
4035
|
+
templateStack[templateStack.length - 1]++
|
|
4036
|
+
} else if (char === '}') {
|
|
4037
|
+
templateStack[templateStack.length - 1]--
|
|
4038
|
+
if (templateStack[templateStack.length - 1] === 0) {
|
|
4039
|
+
templateStack.pop()
|
|
4040
|
+
i++
|
|
4041
|
+
state = 'template-string'
|
|
4042
|
+
continue
|
|
4043
|
+
}
|
|
4044
|
+
}
|
|
4045
|
+
}
|
|
4046
|
+
if (char === "'") {
|
|
4047
|
+
i++
|
|
4048
|
+
state = 'single-string'
|
|
4049
|
+
continue
|
|
4050
|
+
}
|
|
4051
|
+
if (char === '"') {
|
|
4052
|
+
i++
|
|
4053
|
+
state = 'double-string'
|
|
4054
|
+
continue
|
|
4055
|
+
}
|
|
4056
|
+
if (char === '`') {
|
|
4057
|
+
i++
|
|
4058
|
+
state = 'template-string'
|
|
4059
|
+
continue
|
|
4060
|
+
}
|
|
4061
|
+
if (char === '/' && nextChar === '/') {
|
|
4062
|
+
i += 2
|
|
4063
|
+
state = 'line-comment'
|
|
4064
|
+
continue
|
|
4065
|
+
}
|
|
4066
|
+
if (char === '/' && nextChar === '*') {
|
|
4067
|
+
i += 2
|
|
4068
|
+
state = 'block-comment'
|
|
4069
|
+
continue
|
|
4070
|
+
}
|
|
4071
|
+
if (char === '/') {
|
|
4072
|
+
let j = i - 1
|
|
4073
|
+
while (j >= 0 && /\s/.test(source[j])) j--
|
|
4074
|
+
const beforeChar = j >= 0 ? source[j] : ''
|
|
4075
|
+
const isRegexContext =
|
|
4076
|
+
!beforeChar ||
|
|
4077
|
+
/[=(!,;:{[&|?+\-*%<>~^]/.test(beforeChar) ||
|
|
4078
|
+
(j >= 5 &&
|
|
4079
|
+
/\b(return|case|throw|in|of|typeof|instanceof|new|delete|void)$/.test(
|
|
4080
|
+
source.slice(Math.max(0, j - 10), j + 1)
|
|
4081
|
+
))
|
|
4082
|
+
if (isRegexContext) {
|
|
4083
|
+
i++
|
|
4084
|
+
state = 'regex'
|
|
4085
|
+
continue
|
|
4086
|
+
}
|
|
4087
|
+
}
|
|
4088
|
+
|
|
4089
|
+
// Detect `let <ident> :` at top-level normal state
|
|
4090
|
+
if (
|
|
4091
|
+
char === 'l' &&
|
|
4092
|
+
source.slice(i, i + 4) === 'let ' &&
|
|
4093
|
+
(i === 0 || !/[\w$]/.test(source[i - 1]))
|
|
4094
|
+
) {
|
|
4095
|
+
// Skip past `let` and whitespace
|
|
4096
|
+
let j = i + 4
|
|
4097
|
+
while (j < source.length && /\s/.test(source[j])) j++
|
|
4098
|
+
// Match identifier
|
|
4099
|
+
if (j < source.length && /[a-zA-Z_$]/.test(source[j])) {
|
|
4100
|
+
const nameStart = j
|
|
4101
|
+
while (j < source.length && /[\w$]/.test(source[j])) j++
|
|
4102
|
+
const nameEnd = j
|
|
4103
|
+
const varName = source.slice(nameStart, nameEnd)
|
|
4104
|
+
// Skip whitespace; require a `:` (not `::` or part of `?:`)
|
|
4105
|
+
let k = j
|
|
4106
|
+
while (k < source.length && /\s/.test(source[k])) k++
|
|
4107
|
+
if (
|
|
4108
|
+
k < source.length &&
|
|
4109
|
+
source[k] === ':' &&
|
|
4110
|
+
source[k + 1] !== ':'
|
|
4111
|
+
) {
|
|
4112
|
+
const colonPos = k
|
|
4113
|
+
// Skip whitespace after colon
|
|
4114
|
+
let exStart = colonPos + 1
|
|
4115
|
+
while (exStart < source.length && /[ \t]/.test(source[exStart])) {
|
|
4116
|
+
exStart++
|
|
4117
|
+
}
|
|
4118
|
+
// Scan example expression until `=`, `,`, `;`, or newline at depth 0
|
|
4119
|
+
const exEnd = scanExampleEnd(source, exStart)
|
|
4120
|
+
if (exEnd > exStart) {
|
|
4121
|
+
const example = source.slice(exStart, exEnd).trim()
|
|
4122
|
+
annotations.set(varName, example)
|
|
4123
|
+
replacements.push({
|
|
4124
|
+
start: nameEnd,
|
|
4125
|
+
end: exEnd,
|
|
4126
|
+
replacement: '',
|
|
4127
|
+
})
|
|
4128
|
+
i = exEnd
|
|
4129
|
+
continue
|
|
4130
|
+
}
|
|
4131
|
+
}
|
|
4132
|
+
}
|
|
4133
|
+
}
|
|
4134
|
+
break
|
|
4135
|
+
}
|
|
4136
|
+
i++
|
|
4137
|
+
}
|
|
4138
|
+
|
|
4139
|
+
if (replacements.length === 0) return { source, annotations }
|
|
4140
|
+
|
|
4141
|
+
// Apply right-to-left to preserve positions
|
|
4142
|
+
let result = source
|
|
4143
|
+
for (let k = replacements.length - 1; k >= 0; k--) {
|
|
4144
|
+
const r = replacements[k]
|
|
4145
|
+
result = result.slice(0, r.start) + r.replacement + result.slice(r.end)
|
|
4146
|
+
}
|
|
4147
|
+
return { source: result, annotations }
|
|
4148
|
+
}
|
|
4149
|
+
|
|
4150
|
+
/**
|
|
4151
|
+
* Scan forward from `start` and return the position where the example
|
|
4152
|
+
* expression ends. Stops at `=`, `,`, `;`, or a newline when paren/brace/
|
|
4153
|
+
* bracket depth is 0. Skips through nested brackets, strings, and templates.
|
|
4154
|
+
*/
|
|
4155
|
+
function scanExampleEnd(source: string, start: number): number {
|
|
4156
|
+
let i = start
|
|
4157
|
+
let parens = 0
|
|
4158
|
+
let braces = 0
|
|
4159
|
+
let brackets = 0
|
|
4160
|
+
let state: 'normal' | 'sq' | 'dq' | 'tpl' = 'normal'
|
|
4161
|
+
const templateStack: number[] = []
|
|
4162
|
+
while (i < source.length) {
|
|
4163
|
+
const c = source[i]
|
|
4164
|
+
if (state === 'sq') {
|
|
4165
|
+
if (c === '\\') {
|
|
4166
|
+
i += 2
|
|
4167
|
+
continue
|
|
4168
|
+
}
|
|
4169
|
+
if (c === "'") state = 'normal'
|
|
4170
|
+
i++
|
|
4171
|
+
continue
|
|
4172
|
+
}
|
|
4173
|
+
if (state === 'dq') {
|
|
4174
|
+
if (c === '\\') {
|
|
4175
|
+
i += 2
|
|
4176
|
+
continue
|
|
4177
|
+
}
|
|
4178
|
+
if (c === '"') state = 'normal'
|
|
4179
|
+
i++
|
|
4180
|
+
continue
|
|
4181
|
+
}
|
|
4182
|
+
if (state === 'tpl') {
|
|
4183
|
+
if (c === '\\') {
|
|
4184
|
+
i += 2
|
|
4185
|
+
continue
|
|
4186
|
+
}
|
|
4187
|
+
if (c === '$' && source[i + 1] === '{') {
|
|
4188
|
+
templateStack.push(1)
|
|
4189
|
+
state = 'normal'
|
|
4190
|
+
i += 2
|
|
4191
|
+
continue
|
|
4192
|
+
}
|
|
4193
|
+
if (c === '`') state = 'normal'
|
|
4194
|
+
i++
|
|
4195
|
+
continue
|
|
4196
|
+
}
|
|
4197
|
+
// normal
|
|
4198
|
+
if (templateStack.length > 0) {
|
|
4199
|
+
if (c === '{') templateStack[templateStack.length - 1]++
|
|
4200
|
+
else if (c === '}') {
|
|
4201
|
+
templateStack[templateStack.length - 1]--
|
|
4202
|
+
if (templateStack[templateStack.length - 1] === 0) {
|
|
4203
|
+
templateStack.pop()
|
|
4204
|
+
state = 'tpl'
|
|
4205
|
+
i++
|
|
4206
|
+
continue
|
|
4207
|
+
}
|
|
4208
|
+
}
|
|
4209
|
+
}
|
|
4210
|
+
if (c === "'") {
|
|
4211
|
+
state = 'sq'
|
|
4212
|
+
i++
|
|
4213
|
+
continue
|
|
4214
|
+
}
|
|
4215
|
+
if (c === '"') {
|
|
4216
|
+
state = 'dq'
|
|
4217
|
+
i++
|
|
4218
|
+
continue
|
|
4219
|
+
}
|
|
4220
|
+
if (c === '`') {
|
|
4221
|
+
state = 'tpl'
|
|
4222
|
+
i++
|
|
4223
|
+
continue
|
|
4224
|
+
}
|
|
4225
|
+
if (c === '(') parens++
|
|
4226
|
+
else if (c === ')') parens--
|
|
4227
|
+
else if (c === '{') braces++
|
|
4228
|
+
else if (c === '}') braces--
|
|
4229
|
+
else if (c === '[') brackets++
|
|
4230
|
+
else if (c === ']') brackets--
|
|
4231
|
+
if (parens === 0 && braces === 0 && brackets === 0) {
|
|
4232
|
+
if (c === '=' || c === ',' || c === ';' || c === '\n') return i
|
|
4233
|
+
}
|
|
4234
|
+
i++
|
|
4235
|
+
}
|
|
4236
|
+
return i
|
|
4237
|
+
}
|