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.
Files changed (70) hide show
  1. package/CLAUDE.md +99 -33
  2. package/bin/docs.js +4 -1
  3. package/demo/docs.json +104 -22
  4. package/demo/src/examples.test.ts +1 -0
  5. package/demo/src/imports.test.ts +16 -4
  6. package/demo/src/imports.ts +60 -15
  7. package/demo/src/playground-shared.ts +9 -8
  8. package/demo/src/tfs-worker.js +205 -147
  9. package/demo/src/tjs-playground.ts +34 -10
  10. package/demo/src/ts-examples.ts +8 -8
  11. package/demo/src/ts-playground.ts +24 -8
  12. package/dist/index.js +118 -101
  13. package/dist/index.js.map +4 -4
  14. package/dist/src/lang/bool-coercion.d.ts +50 -0
  15. package/dist/src/lang/docs.d.ts +31 -6
  16. package/dist/src/lang/linter.d.ts +8 -0
  17. package/dist/src/lang/parser-transforms.d.ts +18 -0
  18. package/dist/src/lang/parser-types.d.ts +2 -0
  19. package/dist/src/lang/parser.d.ts +3 -0
  20. package/dist/src/lang/runtime.d.ts +34 -0
  21. package/dist/src/lang/types.d.ts +9 -1
  22. package/dist/src/rbac/index.d.ts +1 -1
  23. package/dist/src/vm/runtime.d.ts +1 -1
  24. package/dist/tjs-eval.js +38 -36
  25. package/dist/tjs-eval.js.map +4 -4
  26. package/dist/tjs-from-ts.js +20 -20
  27. package/dist/tjs-from-ts.js.map +3 -3
  28. package/dist/tjs-lang.js +85 -83
  29. package/dist/tjs-lang.js.map +4 -4
  30. package/dist/tjs-vm.js +47 -45
  31. package/dist/tjs-vm.js.map +4 -4
  32. package/llms.txt +79 -0
  33. package/package.json +9 -4
  34. package/src/cli/commands/convert.test.ts +16 -21
  35. package/src/lang/bool-coercion.test.ts +203 -0
  36. package/src/lang/bool-coercion.ts +314 -0
  37. package/src/lang/codegen.test.ts +137 -0
  38. package/src/lang/docs.test.ts +476 -1
  39. package/src/lang/docs.ts +471 -37
  40. package/src/lang/emitters/ast.ts +11 -12
  41. package/src/lang/emitters/dts.test.ts +41 -0
  42. package/src/lang/emitters/dts.ts +9 -0
  43. package/src/lang/emitters/js-tests.ts +9 -4
  44. package/src/lang/emitters/js-wasm.ts +57 -65
  45. package/src/lang/emitters/js.ts +198 -3
  46. package/src/lang/features.test.ts +4 -3
  47. package/src/lang/index.ts +9 -0
  48. package/src/lang/inference.ts +54 -0
  49. package/src/lang/linter.test.ts +104 -1
  50. package/src/lang/linter.ts +124 -1
  51. package/src/lang/module-loader.test.ts +318 -0
  52. package/src/lang/module-loader.ts +419 -0
  53. package/src/lang/parser-params.ts +31 -0
  54. package/src/lang/parser-transforms.ts +640 -0
  55. package/src/lang/parser-types.ts +35 -0
  56. package/src/lang/parser.test.ts +73 -1
  57. package/src/lang/parser.ts +77 -3
  58. package/src/lang/runtime.ts +98 -0
  59. package/src/lang/types.ts +6 -0
  60. package/src/lang/wasm.test.ts +1293 -2
  61. package/src/lang/wasm.ts +470 -87
  62. package/src/linalg/index.tjs +119 -0
  63. package/src/linalg/linalg.test.ts +294 -0
  64. package/src/linalg/vector-search.bench.test.ts +395 -0
  65. package/src/rbac/index.ts +2 -2
  66. package/src/rbac/rules.tjs.d.ts +9 -0
  67. package/src/vm/atoms/batteries.ts +2 -2
  68. package/src/vm/runtime.ts +10 -3
  69. package/dist/src/rbac/rules.d.ts +0 -184
  70. 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
+ }