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.
- package/CLAUDE.md +101 -26
- package/bin/docs.js +4 -1
- package/demo/docs.json +46 -12
- 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-playground.ts +24 -8
- package/dist/index.js +140 -119
- 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 +9 -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 +44 -39
- 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 +86 -80
- package/dist/tjs-lang.js.map +4 -4
- package/dist/tjs-vm.js +50 -45
- package/dist/tjs-vm.js.map +4 -4
- package/llms.txt +79 -0
- package/package.json +3 -2
- 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 +177 -0
- package/src/lang/docs.test.ts +328 -1
- package/src/lang/docs.ts +424 -24
- 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 +16 -4
- package/src/lang/emitters/js.ts +208 -2
- package/src/lang/features.test.ts +22 -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/parser-params.ts +31 -0
- package/src/lang/parser-transforms.ts +539 -6
- package/src/lang/parser-types.ts +2 -0
- package/src/lang/parser.test.ts +73 -1
- package/src/lang/parser.ts +85 -1
- package/src/lang/runtime.ts +98 -0
- package/src/lang/tests.ts +21 -8
- package/src/lang/types.ts +6 -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
|
@@ -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
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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
|
+
}
|
package/src/lang/parser-types.ts
CHANGED
|
@@ -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
|
/**
|
package/src/lang/parser.test.ts
CHANGED
|
@@ -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
|
})
|