tjs-lang 0.7.6 → 0.7.8

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 (61) hide show
  1. package/CLAUDE.md +101 -26
  2. package/bin/docs.js +4 -1
  3. package/demo/docs.json +46 -12
  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-playground.ts +24 -8
  11. package/dist/index.js +140 -119
  12. package/dist/index.js.map +4 -4
  13. package/dist/src/lang/bool-coercion.d.ts +50 -0
  14. package/dist/src/lang/docs.d.ts +31 -6
  15. package/dist/src/lang/linter.d.ts +8 -0
  16. package/dist/src/lang/parser-transforms.d.ts +18 -0
  17. package/dist/src/lang/parser-types.d.ts +2 -0
  18. package/dist/src/lang/parser.d.ts +9 -0
  19. package/dist/src/lang/runtime.d.ts +34 -0
  20. package/dist/src/lang/types.d.ts +9 -1
  21. package/dist/src/rbac/index.d.ts +1 -1
  22. package/dist/src/vm/runtime.d.ts +1 -1
  23. package/dist/tjs-eval.js +44 -39
  24. package/dist/tjs-eval.js.map +4 -4
  25. package/dist/tjs-from-ts.js +20 -20
  26. package/dist/tjs-from-ts.js.map +3 -3
  27. package/dist/tjs-lang.js +86 -80
  28. package/dist/tjs-lang.js.map +4 -4
  29. package/dist/tjs-vm.js +50 -45
  30. package/dist/tjs-vm.js.map +4 -4
  31. package/llms.txt +79 -0
  32. package/package.json +3 -2
  33. package/src/cli/commands/convert.test.ts +16 -21
  34. package/src/lang/bool-coercion.test.ts +203 -0
  35. package/src/lang/bool-coercion.ts +314 -0
  36. package/src/lang/codegen.test.ts +177 -0
  37. package/src/lang/docs.test.ts +328 -1
  38. package/src/lang/docs.ts +424 -24
  39. package/src/lang/emitters/ast.ts +11 -12
  40. package/src/lang/emitters/dts.test.ts +41 -0
  41. package/src/lang/emitters/dts.ts +9 -0
  42. package/src/lang/emitters/js-tests.ts +16 -4
  43. package/src/lang/emitters/js.ts +208 -2
  44. package/src/lang/features.test.ts +22 -0
  45. package/src/lang/inference.ts +54 -0
  46. package/src/lang/linter.test.ts +104 -1
  47. package/src/lang/linter.ts +124 -1
  48. package/src/lang/parser-params.ts +31 -0
  49. package/src/lang/parser-transforms.ts +539 -6
  50. package/src/lang/parser-types.ts +2 -0
  51. package/src/lang/parser.test.ts +73 -1
  52. package/src/lang/parser.ts +85 -1
  53. package/src/lang/runtime.ts +98 -0
  54. package/src/lang/tests.ts +21 -8
  55. package/src/lang/types.ts +6 -0
  56. package/src/rbac/index.ts +2 -2
  57. package/src/rbac/rules.tjs.d.ts +9 -0
  58. package/src/vm/atoms/batteries.ts +2 -2
  59. package/src/vm/runtime.ts +10 -3
  60. package/dist/src/rbac/rules.d.ts +0 -184
  61. package/src/rbac/rules.js +0 -338
@@ -593,6 +593,201 @@ export function insertAsiProtection(source: string): string {
593
593
  return result.join('\n')
594
594
  }
595
595
 
596
+ /**
597
+ * Replace `typeof X` with `TypeOf(X)` outside string/template/comment/regex bodies.
598
+ * X may be `ident`, `ident.foo`, `ident?.foo`, etc. Anything that doesn't look
599
+ * like a simple identifier expression (e.g. `typeof (x + 1)`) is left alone.
600
+ */
601
+ function transformTypeofKeyword(source: string): string {
602
+ type Match = { keywordStart: number; operandEnd: number; operand: string }
603
+ const matches: Match[] = []
604
+ let i = 0
605
+ let state: TokenizerState = 'normal'
606
+ const templateStack: number[] = []
607
+
608
+ while (i < source.length) {
609
+ const char = source[i]
610
+ const nextChar = source[i + 1]
611
+
612
+ switch (state) {
613
+ case 'single-string':
614
+ if (char === '\\' && i + 1 < source.length) {
615
+ i += 2
616
+ continue
617
+ }
618
+ if (char === "'") state = 'normal'
619
+ i++
620
+ continue
621
+ case 'double-string':
622
+ if (char === '\\' && i + 1 < source.length) {
623
+ i += 2
624
+ continue
625
+ }
626
+ if (char === '"') state = 'normal'
627
+ i++
628
+ continue
629
+ case 'template-string':
630
+ if (char === '\\' && i + 1 < source.length) {
631
+ i += 2
632
+ continue
633
+ }
634
+ if (char === '$' && nextChar === '{') {
635
+ i += 2
636
+ templateStack.push(1)
637
+ state = 'normal'
638
+ continue
639
+ }
640
+ if (char === '`') state = 'normal'
641
+ i++
642
+ continue
643
+ case 'line-comment':
644
+ if (char === '\n') state = 'normal'
645
+ i++
646
+ continue
647
+ case 'block-comment':
648
+ if (char === '*' && nextChar === '/') {
649
+ i += 2
650
+ state = 'normal'
651
+ continue
652
+ }
653
+ i++
654
+ continue
655
+ case 'regex':
656
+ if (char === '\\' && i + 1 < source.length) {
657
+ i += 2
658
+ continue
659
+ }
660
+ if (char === '[') {
661
+ i++
662
+ while (i < source.length && source[i] !== ']') {
663
+ if (source[i] === '\\' && i + 1 < source.length) i += 2
664
+ else i++
665
+ }
666
+ if (i < source.length) i++
667
+ continue
668
+ }
669
+ if (char === '/') {
670
+ i++
671
+ while (i < source.length && /[gimsuy]/.test(source[i])) i++
672
+ state = 'normal'
673
+ continue
674
+ }
675
+ i++
676
+ continue
677
+ case 'normal':
678
+ if (templateStack.length > 0) {
679
+ if (char === '{') {
680
+ templateStack[templateStack.length - 1]++
681
+ } else if (char === '}') {
682
+ templateStack[templateStack.length - 1]--
683
+ if (templateStack[templateStack.length - 1] === 0) {
684
+ templateStack.pop()
685
+ i++
686
+ state = 'template-string'
687
+ continue
688
+ }
689
+ }
690
+ }
691
+ if (char === "'") {
692
+ i++
693
+ state = 'single-string'
694
+ continue
695
+ }
696
+ if (char === '"') {
697
+ i++
698
+ state = 'double-string'
699
+ continue
700
+ }
701
+ if (char === '`') {
702
+ i++
703
+ state = 'template-string'
704
+ continue
705
+ }
706
+ if (char === '/' && nextChar === '/') {
707
+ i += 2
708
+ state = 'line-comment'
709
+ continue
710
+ }
711
+ if (char === '/' && nextChar === '*') {
712
+ i += 2
713
+ state = 'block-comment'
714
+ continue
715
+ }
716
+ if (char === '/') {
717
+ let j = i - 1
718
+ while (j >= 0 && /\s/.test(source[j])) j--
719
+ const beforeChar = j >= 0 ? source[j] : ''
720
+ const isRegexContext =
721
+ !beforeChar ||
722
+ /[=(!,;:{[&|?+\-*%<>~^]/.test(beforeChar) ||
723
+ (j >= 5 &&
724
+ /\b(return|case|throw|in|of|typeof|instanceof|new|delete|void)$/.test(
725
+ source.slice(Math.max(0, j - 10), j + 1)
726
+ ))
727
+ if (isRegexContext) {
728
+ i++
729
+ state = 'regex'
730
+ continue
731
+ }
732
+ }
733
+
734
+ // Detect `typeof <ident-chain>` — only when preceded by a non-word
735
+ // boundary and followed by whitespace then an identifier.
736
+ if (
737
+ char === 't' &&
738
+ source.slice(i, i + 6) === 'typeof' &&
739
+ (i === 0 || !/[\w$]/.test(source[i - 1])) &&
740
+ /\s/.test(source[i + 6] ?? '')
741
+ ) {
742
+ let j = i + 6
743
+ while (j < source.length && /\s/.test(source[j])) j++
744
+ if (j < source.length && /[a-zA-Z_$]/.test(source[j])) {
745
+ const operandStart = j
746
+ while (j < source.length && /[\w$]/.test(source[j])) j++
747
+ // Optional `.name` or `?.name` chains
748
+ while (j < source.length) {
749
+ if (source[j] === '.' && /[a-zA-Z_$]/.test(source[j + 1] ?? '')) {
750
+ j++
751
+ while (j < source.length && /[\w$]/.test(source[j])) j++
752
+ } else if (
753
+ source[j] === '?' &&
754
+ source[j + 1] === '.' &&
755
+ /[a-zA-Z_$]/.test(source[j + 2] ?? '')
756
+ ) {
757
+ j += 2
758
+ while (j < source.length && /[\w$]/.test(source[j])) j++
759
+ } else {
760
+ break
761
+ }
762
+ }
763
+ matches.push({
764
+ keywordStart: i,
765
+ operandEnd: j,
766
+ operand: source.slice(operandStart, j),
767
+ })
768
+ i = j
769
+ continue
770
+ }
771
+ }
772
+ break
773
+ }
774
+ i++
775
+ }
776
+
777
+ if (matches.length === 0) return source
778
+
779
+ // Apply replacements from end to start so earlier positions remain valid.
780
+ let result = source
781
+ for (let k = matches.length - 1; k >= 0; k--) {
782
+ const m = matches[k]
783
+ result =
784
+ result.slice(0, m.keywordStart) +
785
+ `TypeOf(${m.operand})` +
786
+ result.slice(m.operandEnd)
787
+ }
788
+ return result
789
+ }
790
+
596
791
  /**
597
792
  * Transform == and != to Is() and IsNot() calls
598
793
  *
@@ -606,11 +801,11 @@ export function insertAsiProtection(source: string): string {
606
801
  * 2. Transform from end to start (so positions remain valid)
607
802
  */
608
803
  export function transformEqualityToStructural(source: string): string {
609
- // Transform typeof to TypeOf() — fixes typeof null === 'object'
610
- source = source.replace(
611
- /\btypeof\s+([a-zA-Z_$][\w$.]*(?:\?\.[\w$]+)*)/g,
612
- 'TypeOf($1)'
613
- )
804
+ // Transform typeof to TypeOf() — fixes typeof null === 'object'.
805
+ // Uses the same tokenizer state machine as the equality pass so it skips
806
+ // string/template/comment/regex bodies (a regex replace would rewrite
807
+ // 'typeof x' inside string literals).
808
+ source = transformTypeofKeyword(source)
614
809
 
615
810
  // First pass: find all == and != positions (outside strings/comments/regex)
616
811
  const equalityOps: Array<{ pos: number; op: '==' | '!=' }> = []
@@ -2642,9 +2837,43 @@ export function extractAndRunTests(
2642
2837
  let depth = 1
2643
2838
  let k = bodyStart
2644
2839
 
2645
- // Find matching closing brace
2840
+ // Find matching closing brace (skip strings and comments)
2841
+ let inStr: string | null = null
2842
+ let escaped = false
2646
2843
  while (k < source.length && depth > 0) {
2647
2844
  const char = source[k]
2845
+ if (escaped) {
2846
+ escaped = false
2847
+ k++
2848
+ continue
2849
+ }
2850
+ if (char === '\\' && inStr) {
2851
+ escaped = true
2852
+ k++
2853
+ continue
2854
+ }
2855
+ if (inStr) {
2856
+ if (char === inStr) inStr = null
2857
+ k++
2858
+ continue
2859
+ }
2860
+ // Line comment — skip to end of line
2861
+ if (char === '/' && source[k + 1] === '/') {
2862
+ const nl = source.indexOf('\n', k)
2863
+ k = nl === -1 ? source.length : nl + 1
2864
+ continue
2865
+ }
2866
+ // Block comment — skip to */
2867
+ if (char === '/' && source[k + 1] === '*') {
2868
+ const end = source.indexOf('*/', k + 2)
2869
+ k = end === -1 ? source.length : end + 2
2870
+ continue
2871
+ }
2872
+ if (char === "'" || char === '"' || char === '`') {
2873
+ inStr = char
2874
+ k++
2875
+ continue
2876
+ }
2648
2877
  if (char === '{') depth++
2649
2878
  else if (char === '}') depth--
2650
2879
  k++
@@ -3366,3 +3595,307 @@ function findMatchingOpen(
3366
3595
  }
3367
3596
  return pos
3368
3597
  }
3598
+
3599
+ /**
3600
+ * Transform `let x: <example>` and `let x: <example> = value` declarations.
3601
+ *
3602
+ * Strips the `: <example>` annotation so Acorn can parse, and records the
3603
+ * variable name + example text so the linter and (later) type inference can
3604
+ * use the annotation. Acorn rejects the colon since it is not valid JS.
3605
+ *
3606
+ * let x: '' → let x (annotation: x → '')
3607
+ * let x: 0 = 5 → let x = 5 (annotation: x → 0)
3608
+ * let result: { ok: false } = ... (annotation: result → { ok: false })
3609
+ *
3610
+ * Only `let` is processed. `const` always has an initializer, so the type
3611
+ * is always inferable. `var` is rejected by TjsNoVar mode.
3612
+ */
3613
+ export function transformLetTypeAnnotations(source: string): {
3614
+ source: string
3615
+ annotations: Map<string, string>
3616
+ } {
3617
+ const annotations = new Map<string, string>()
3618
+ if (!source.includes('let ')) return { source, annotations }
3619
+
3620
+ type Replacement = { start: number; end: number; replacement: string }
3621
+ const replacements: Replacement[] = []
3622
+
3623
+ let i = 0
3624
+ let state: TokenizerState = 'normal'
3625
+ const templateStack: number[] = []
3626
+
3627
+ while (i < source.length) {
3628
+ const char = source[i]
3629
+ const nextChar = source[i + 1]
3630
+
3631
+ switch (state) {
3632
+ case 'single-string':
3633
+ if (char === '\\' && i + 1 < source.length) {
3634
+ i += 2
3635
+ continue
3636
+ }
3637
+ if (char === "'") state = 'normal'
3638
+ i++
3639
+ continue
3640
+ case 'double-string':
3641
+ if (char === '\\' && i + 1 < source.length) {
3642
+ i += 2
3643
+ continue
3644
+ }
3645
+ if (char === '"') state = 'normal'
3646
+ i++
3647
+ continue
3648
+ case 'template-string':
3649
+ if (char === '\\' && i + 1 < source.length) {
3650
+ i += 2
3651
+ continue
3652
+ }
3653
+ if (char === '$' && nextChar === '{') {
3654
+ i += 2
3655
+ templateStack.push(1)
3656
+ state = 'normal'
3657
+ continue
3658
+ }
3659
+ if (char === '`') state = 'normal'
3660
+ i++
3661
+ continue
3662
+ case 'line-comment':
3663
+ if (char === '\n') state = 'normal'
3664
+ i++
3665
+ continue
3666
+ case 'block-comment':
3667
+ if (char === '*' && nextChar === '/') {
3668
+ i += 2
3669
+ state = 'normal'
3670
+ continue
3671
+ }
3672
+ i++
3673
+ continue
3674
+ case 'regex':
3675
+ if (char === '\\' && i + 1 < source.length) {
3676
+ i += 2
3677
+ continue
3678
+ }
3679
+ if (char === '[') {
3680
+ i++
3681
+ while (i < source.length && source[i] !== ']') {
3682
+ if (source[i] === '\\' && i + 1 < source.length) i += 2
3683
+ else i++
3684
+ }
3685
+ if (i < source.length) i++
3686
+ continue
3687
+ }
3688
+ if (char === '/') {
3689
+ i++
3690
+ while (i < source.length && /[gimsuy]/.test(source[i])) i++
3691
+ state = 'normal'
3692
+ continue
3693
+ }
3694
+ i++
3695
+ continue
3696
+ case 'normal':
3697
+ if (templateStack.length > 0) {
3698
+ if (char === '{') {
3699
+ templateStack[templateStack.length - 1]++
3700
+ } else if (char === '}') {
3701
+ templateStack[templateStack.length - 1]--
3702
+ if (templateStack[templateStack.length - 1] === 0) {
3703
+ templateStack.pop()
3704
+ i++
3705
+ state = 'template-string'
3706
+ continue
3707
+ }
3708
+ }
3709
+ }
3710
+ if (char === "'") {
3711
+ i++
3712
+ state = 'single-string'
3713
+ continue
3714
+ }
3715
+ if (char === '"') {
3716
+ i++
3717
+ state = 'double-string'
3718
+ continue
3719
+ }
3720
+ if (char === '`') {
3721
+ i++
3722
+ state = 'template-string'
3723
+ continue
3724
+ }
3725
+ if (char === '/' && nextChar === '/') {
3726
+ i += 2
3727
+ state = 'line-comment'
3728
+ continue
3729
+ }
3730
+ if (char === '/' && nextChar === '*') {
3731
+ i += 2
3732
+ state = 'block-comment'
3733
+ continue
3734
+ }
3735
+ if (char === '/') {
3736
+ let j = i - 1
3737
+ while (j >= 0 && /\s/.test(source[j])) j--
3738
+ const beforeChar = j >= 0 ? source[j] : ''
3739
+ const isRegexContext =
3740
+ !beforeChar ||
3741
+ /[=(!,;:{[&|?+\-*%<>~^]/.test(beforeChar) ||
3742
+ (j >= 5 &&
3743
+ /\b(return|case|throw|in|of|typeof|instanceof|new|delete|void)$/.test(
3744
+ source.slice(Math.max(0, j - 10), j + 1)
3745
+ ))
3746
+ if (isRegexContext) {
3747
+ i++
3748
+ state = 'regex'
3749
+ continue
3750
+ }
3751
+ }
3752
+
3753
+ // Detect `let <ident> :` at top-level normal state
3754
+ if (
3755
+ char === 'l' &&
3756
+ source.slice(i, i + 4) === 'let ' &&
3757
+ (i === 0 || !/[\w$]/.test(source[i - 1]))
3758
+ ) {
3759
+ // Skip past `let` and whitespace
3760
+ let j = i + 4
3761
+ while (j < source.length && /\s/.test(source[j])) j++
3762
+ // Match identifier
3763
+ if (j < source.length && /[a-zA-Z_$]/.test(source[j])) {
3764
+ const nameStart = j
3765
+ while (j < source.length && /[\w$]/.test(source[j])) j++
3766
+ const nameEnd = j
3767
+ const varName = source.slice(nameStart, nameEnd)
3768
+ // Skip whitespace; require a `:` (not `::` or part of `?:`)
3769
+ let k = j
3770
+ while (k < source.length && /\s/.test(source[k])) k++
3771
+ if (
3772
+ k < source.length &&
3773
+ source[k] === ':' &&
3774
+ source[k + 1] !== ':'
3775
+ ) {
3776
+ const colonPos = k
3777
+ // Skip whitespace after colon
3778
+ let exStart = colonPos + 1
3779
+ while (exStart < source.length && /[ \t]/.test(source[exStart])) {
3780
+ exStart++
3781
+ }
3782
+ // Scan example expression until `=`, `,`, `;`, or newline at depth 0
3783
+ const exEnd = scanExampleEnd(source, exStart)
3784
+ if (exEnd > exStart) {
3785
+ const example = source.slice(exStart, exEnd).trim()
3786
+ annotations.set(varName, example)
3787
+ replacements.push({
3788
+ start: nameEnd,
3789
+ end: exEnd,
3790
+ replacement: '',
3791
+ })
3792
+ i = exEnd
3793
+ continue
3794
+ }
3795
+ }
3796
+ }
3797
+ }
3798
+ break
3799
+ }
3800
+ i++
3801
+ }
3802
+
3803
+ if (replacements.length === 0) return { source, annotations }
3804
+
3805
+ // Apply right-to-left to preserve positions
3806
+ let result = source
3807
+ for (let k = replacements.length - 1; k >= 0; k--) {
3808
+ const r = replacements[k]
3809
+ result = result.slice(0, r.start) + r.replacement + result.slice(r.end)
3810
+ }
3811
+ return { source: result, annotations }
3812
+ }
3813
+
3814
+ /**
3815
+ * Scan forward from `start` and return the position where the example
3816
+ * expression ends. Stops at `=`, `,`, `;`, or a newline when paren/brace/
3817
+ * bracket depth is 0. Skips through nested brackets, strings, and templates.
3818
+ */
3819
+ function scanExampleEnd(source: string, start: number): number {
3820
+ let i = start
3821
+ let parens = 0
3822
+ let braces = 0
3823
+ let brackets = 0
3824
+ let state: 'normal' | 'sq' | 'dq' | 'tpl' = 'normal'
3825
+ const templateStack: number[] = []
3826
+ while (i < source.length) {
3827
+ const c = source[i]
3828
+ if (state === 'sq') {
3829
+ if (c === '\\') {
3830
+ i += 2
3831
+ continue
3832
+ }
3833
+ if (c === "'") state = 'normal'
3834
+ i++
3835
+ continue
3836
+ }
3837
+ if (state === 'dq') {
3838
+ if (c === '\\') {
3839
+ i += 2
3840
+ continue
3841
+ }
3842
+ if (c === '"') state = 'normal'
3843
+ i++
3844
+ continue
3845
+ }
3846
+ if (state === 'tpl') {
3847
+ if (c === '\\') {
3848
+ i += 2
3849
+ continue
3850
+ }
3851
+ if (c === '$' && source[i + 1] === '{') {
3852
+ templateStack.push(1)
3853
+ state = 'normal'
3854
+ i += 2
3855
+ continue
3856
+ }
3857
+ if (c === '`') state = 'normal'
3858
+ i++
3859
+ continue
3860
+ }
3861
+ // normal
3862
+ if (templateStack.length > 0) {
3863
+ if (c === '{') templateStack[templateStack.length - 1]++
3864
+ else if (c === '}') {
3865
+ templateStack[templateStack.length - 1]--
3866
+ if (templateStack[templateStack.length - 1] === 0) {
3867
+ templateStack.pop()
3868
+ state = 'tpl'
3869
+ i++
3870
+ continue
3871
+ }
3872
+ }
3873
+ }
3874
+ if (c === "'") {
3875
+ state = 'sq'
3876
+ i++
3877
+ continue
3878
+ }
3879
+ if (c === '"') {
3880
+ state = 'dq'
3881
+ i++
3882
+ continue
3883
+ }
3884
+ if (c === '`') {
3885
+ state = 'tpl'
3886
+ i++
3887
+ continue
3888
+ }
3889
+ if (c === '(') parens++
3890
+ else if (c === ')') parens--
3891
+ else if (c === '{') braces++
3892
+ else if (c === '}') braces--
3893
+ else if (c === '[') brackets++
3894
+ else if (c === ']') brackets--
3895
+ if (parens === 0 && braces === 0 && brackets === 0) {
3896
+ if (c === '=' || c === ',' || c === ';' || c === '\n') return i
3897
+ }
3898
+ i++
3899
+ }
3900
+ return i
3901
+ }
@@ -144,6 +144,8 @@ export interface TjsModes {
144
144
  tjsSafeEval: boolean
145
145
  /** TjsNoVar: var declarations are syntax errors */
146
146
  tjsNoVar: boolean
147
+ /** TjsSafeAssign: let declarations need an initializer or `: example` annotation; literal undefined/null/void 0 assigned to typed lets is flagged */
148
+ tjsSafeAssign: boolean
147
149
  }
148
150
 
149
151
  /**
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect } from 'bun:test'
2
2
  import { transpile, ajs, tjs } from './index'
3
- import { preprocess } from './parser'
3
+ import { preprocess, parse } from './parser'
4
4
  import { createRuntime, isMonadicError } from './runtime'
5
5
 
6
6
  describe('Transpiler', () => {
@@ -1067,4 +1067,76 @@ test 'always fails' { throw new Error('intentional') }
1067
1067
  expect(varSetStep.value.op).toBe('??')
1068
1068
  })
1069
1069
  })
1070
+
1071
+ describe('stray `=>` on function declarations', () => {
1072
+ it('errors clearly on `function f(...): T => { body }`', () => {
1073
+ // A common mistake — writing arrow-function syntax on a regular
1074
+ // function declaration. Without the dedicated check, the `=>` falls
1075
+ // through to Acorn which complains at a misleading position.
1076
+ expect(() =>
1077
+ tjs(`function f(s: '', n = 0): 0 => {
1078
+ return s.length + n
1079
+ }`)
1080
+ ).toThrow(/Unexpected '=>' after function declaration/)
1081
+ })
1082
+
1083
+ it('errors with column pointing at the `=>` (not earlier in the line)', () => {
1084
+ let caught: any
1085
+ try {
1086
+ tjs(`function f(): 0 => { return 0 }`)
1087
+ } catch (e) {
1088
+ caught = e
1089
+ }
1090
+ expect(caught).toBeDefined()
1091
+ // The `=>` is at column 17 (1-indexed): `function f(): 0 ` is 16 chars
1092
+ expect(caught.column).toBe(16)
1093
+ })
1094
+
1095
+ it('does not error on regular function declarations', () => {
1096
+ expect(() =>
1097
+ tjs(`function f(s: ''): 0 { return s.length }`)
1098
+ ).not.toThrow()
1099
+ })
1100
+
1101
+ it('does not error on real arrow function expressions', () => {
1102
+ expect(() => tjs(`const f = (s = '') => s.length`)).not.toThrow()
1103
+ })
1104
+ })
1105
+
1106
+ describe('let type annotations (TjsSafeAssign)', () => {
1107
+ it("strips `: <example>` from `let x: ''` and records annotation", () => {
1108
+ const r = parse(`let x: ''`)
1109
+ expect(r.letAnnotations.get('x')).toBe("''")
1110
+ })
1111
+
1112
+ it('strips `: <example>` from `let x: 0 = 5` and keeps the initializer', () => {
1113
+ const r = parse(`let x: 0 = 5`)
1114
+ expect(r.letAnnotations.get('x')).toBe('0')
1115
+ const decl = r.ast.body[0] as any
1116
+ expect(decl.type).toBe('VariableDeclaration')
1117
+ expect(decl.declarations[0].init).not.toBeNull()
1118
+ })
1119
+
1120
+ it('handles object-literal example with nested braces', () => {
1121
+ const r = parse(
1122
+ `function f() { let result: { ok: false }; return result }`
1123
+ )
1124
+ expect(r.letAnnotations.get('result')).toBe('{ ok: false }')
1125
+ })
1126
+
1127
+ it('does not strip `:` inside string literals', () => {
1128
+ const r = parse(`let s = 'a:b:c'`)
1129
+ expect(r.letAnnotations.size).toBe(0)
1130
+ })
1131
+
1132
+ it('TjsSafeAssign mode is on by default in native TJS', () => {
1133
+ const r = parse(`let x = 0`)
1134
+ expect(r.tjsModes.tjsSafeAssign).toBe(true)
1135
+ })
1136
+
1137
+ it('TjsCompat directive disables TjsSafeAssign', () => {
1138
+ const r = parse(`TjsCompat\nlet x = 0`)
1139
+ expect(r.tjsModes.tjsSafeAssign).toBe(false)
1140
+ })
1141
+ })
1070
1142
  })