rapydscript-ng 0.7.24 → 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 (114) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +72 -26
  3. package/bin/package.json +1 -0
  4. package/bin/rapydscript +65 -62
  5. package/editor-plugins/nvim/rapydscript/README.md +184 -0
  6. package/editor-plugins/nvim/rapydscript/bin/rapydscript.so +0 -0
  7. package/editor-plugins/nvim/rapydscript/ftdetect/rapydscript.lua +10 -0
  8. package/editor-plugins/nvim/rapydscript/lua/rapydscript/health.lua +88 -0
  9. package/editor-plugins/nvim/rapydscript/lua/rapydscript/init.lua +279 -0
  10. package/local-agent.md +28 -0
  11. package/package.json +4 -5
  12. package/release/baselib-plain-pretty.js +448 -326
  13. package/release/baselib-plain-ugly.js +3 -3
  14. package/release/compiler.js +2528 -1672
  15. package/release/signatures.json +17 -17
  16. package/src/ast.pyj +73 -25
  17. package/src/baselib-builtins.pyj +2 -2
  18. package/src/baselib-containers.pyj +153 -151
  19. package/src/baselib-internal.pyj +3 -3
  20. package/src/baselib-str.pyj +5 -5
  21. package/src/lib/aes.pyj +1 -1
  22. package/src/lib/encodings.pyj +1 -1
  23. package/src/lib/pythonize.pyj +1 -1
  24. package/src/lib/re.pyj +2 -2
  25. package/src/lib/traceback.pyj +165 -28
  26. package/src/lib/uuid.pyj +1 -1
  27. package/src/output/codegen.pyj +10 -3
  28. package/src/output/functions.pyj +31 -40
  29. package/src/output/literals.pyj +11 -14
  30. package/src/output/loops.pyj +25 -87
  31. package/src/output/modules.pyj +5 -47
  32. package/src/output/statements.pyj +1 -1
  33. package/src/output/stream.pyj +58 -31
  34. package/src/parse.pyj +777 -427
  35. package/src/tokenizer.pyj +16 -4
  36. package/src/utils.pyj +1 -1
  37. package/test/_treeshake_mod.pyj +30 -0
  38. package/test/_treeshake_side_effect.pyj +9 -0
  39. package/test/ast_serialization.pyj +392 -0
  40. package/test/async.pyj +95 -0
  41. package/test/baselib.pyj +1 -2
  42. package/test/embedded_compiler.pyj +57 -0
  43. package/test/fmt.pyj +201 -0
  44. package/test/generic.pyj +7 -3
  45. package/test/imports.pyj +8 -6
  46. package/test/internationalization.pyj +38 -37
  47. package/test/lint.pyj +120 -117
  48. package/test/lsp.pyj +222 -0
  49. package/test/repl.pyj +70 -65
  50. package/test/sourcemaps.pyj +315 -0
  51. package/test/starargs.pyj +46 -39
  52. package/test/traceback.pyj +263 -0
  53. package/test/treeshaking.pyj +181 -0
  54. package/test/web_repl_export.pyj +88 -0
  55. package/tools/ast_serialize.mjs +557 -0
  56. package/tools/cli.mjs +510 -0
  57. package/tools/compile.mjs +201 -0
  58. package/tools/compiler.mjs +127 -0
  59. package/tools/{completer.js → completer.mjs} +6 -6
  60. package/tools/{embedded_compiler.js → embedded_compiler.mjs} +17 -13
  61. package/tools/export.js +109 -56
  62. package/tools/fmt.mjs +735 -0
  63. package/tools/{gettext.js → gettext.mjs} +46 -53
  64. package/tools/{ini.js → ini.mjs} +9 -10
  65. package/tools/{lint.js → lint.mjs} +78 -55
  66. package/tools/lsp.mjs +914 -0
  67. package/tools/lsp_protocol.mjs +259 -0
  68. package/tools/lsp_symbols.mjs +418 -0
  69. package/tools/{msgfmt.js → msgfmt.mjs} +18 -18
  70. package/tools/{repl.js → repl.mjs} +52 -41
  71. package/tools/{self.js → self.mjs} +56 -46
  72. package/tools/sourcemap.mjs +123 -0
  73. package/tools/test.mjs +152 -0
  74. package/tools/treeshake.mjs +400 -0
  75. package/tools/{utils.js → utils.mjs} +26 -23
  76. package/tools/{web_repl.js → web_repl.mjs} +15 -13
  77. package/tools/web_repl_export.mjs +93 -0
  78. package/tree-sitter/README.md +101 -0
  79. package/tree-sitter/grammar.js +992 -0
  80. package/tree-sitter/package.json +43 -0
  81. package/tree-sitter/queries/rapydscript/highlights.scm +232 -0
  82. package/tree-sitter/queries/rapydscript/indents.scm +64 -0
  83. package/tree-sitter/queries/rapydscript/injections.scm +14 -0
  84. package/tree-sitter/queries/rapydscript/locals.scm +108 -0
  85. package/tree-sitter/src/grammar.json +4976 -0
  86. package/tree-sitter/src/node-types.json +2901 -0
  87. package/tree-sitter/src/parser.c +196465 -0
  88. package/tree-sitter/src/scanner.c +294 -0
  89. package/tree-sitter/src/tree_sitter/alloc.h +54 -0
  90. package/tree-sitter/src/tree_sitter/array.h +330 -0
  91. package/tree-sitter/src/tree_sitter/parser.h +286 -0
  92. package/tree-sitter/test/corpus/chaining.txt +99 -0
  93. package/tree-sitter/test/corpus/classes.txt +147 -0
  94. package/tree-sitter/test/corpus/comprehensions.txt +155 -0
  95. package/tree-sitter/test/corpus/containers.txt +242 -0
  96. package/tree-sitter/test/corpus/control_flow.txt +361 -0
  97. package/tree-sitter/test/corpus/decorators.txt +64 -0
  98. package/tree-sitter/test/corpus/existential.txt +102 -0
  99. package/tree-sitter/test/corpus/functions.txt +293 -0
  100. package/tree-sitter/test/corpus/imports.txt +117 -0
  101. package/tree-sitter/test/corpus/literals.txt +135 -0
  102. package/tree-sitter/test/corpus/operators.txt +296 -0
  103. package/tree-sitter/test/corpus/statements.txt +189 -0
  104. package/tree-sitter/test/corpus/strings.txt +90 -0
  105. package/tree-sitter/test/corpus/subscripts.txt +227 -0
  106. package/tree-sitter/tree-sitter.json +36 -0
  107. package/web-repl/env.js +35 -23
  108. package/web-repl/main.js +8 -8
  109. package/bin/export +0 -75
  110. package/bin/web-repl-export +0 -102
  111. package/tools/cli.js +0 -523
  112. package/tools/compile.js +0 -184
  113. package/tools/compiler.js +0 -84
  114. package/tools/test.js +0 -110
@@ -0,0 +1,263 @@
1
+ # globals: assrt, rs_generate_source_map
2
+ import traceback
3
+
4
+ generate_source_map = rs_generate_source_map
5
+
6
+ # ---------------------------------------------------------------------------
7
+ # _vlq_decode
8
+ # ---------------------------------------------------------------------------
9
+
10
+ # All-zero segment: AAAA → [0, 0, 0, 0]
11
+ assrt.deepEqual(traceback._vlq_decode('AAAA'), [0, 0, 0, 0], 'AAAA → all zeros')
12
+
13
+ # Positive deltas: I=8→4, A=0, A=0, G=6→3
14
+ assrt.deepEqual(traceback._vlq_decode('IAAG'), [4, 0, 0, 3], 'IAAG → [4,0,0,3]')
15
+
16
+ # Source line delta of 1: AACA → [0, 0, 1, 0]
17
+ assrt.deepEqual(traceback._vlq_decode('AACA'), [0, 0, 1, 0], 'AACA → [0,0,1,0]')
18
+
19
+ # Single negative value: D=3 (odd) → -1
20
+ assrt.deepEqual(traceback._vlq_decode('D'), [-1], 'D → [-1]')
21
+
22
+ # Negative source column delta: H=7 (odd) → -3
23
+ assrt.deepEqual(traceback._vlq_decode('IAAH'), [4, 0, 0, -3], 'IAAH → [4,0,0,-3]')
24
+
25
+ # Multi-byte VLQ: k has continuation bit, kD → 50
26
+ assrt.deepEqual(traceback._vlq_decode('kD'), [50], 'kD → [50] multi-byte')
27
+
28
+ # Multi-byte VLQ in a full four-field segment
29
+ assrt.deepEqual(traceback._vlq_decode('kDAAkD'), [50, 0, 0, 50], 'kDAAkD → [50,0,0,50]')
30
+
31
+ # Empty string → empty array
32
+ assrt.deepEqual(traceback._vlq_decode(''), v'[]', 'empty string → empty result')
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # _decode_source_map
36
+ # ---------------------------------------------------------------------------
37
+
38
+ # Empty mappings → one line group with no entries
39
+ decoded = traceback._decode_source_map({'version': 3, 'sources': v'[]', 'mappings': '', 'sourceRoot': ''})
40
+ assrt.strictEqual(decoded.length, 1, 'empty mappings → 1 line group')
41
+ assrt.strictEqual(decoded[0].length, 0, 'that group has no entries')
42
+
43
+ # Single segment at origin
44
+ smap = JSON.parse(generate_source_map([[0, 0, 'a.pyj', 0, 0]], 'out.js', ''))
45
+ decoded = traceback._decode_source_map(smap)
46
+ assrt.ok(decoded.length >= 1, 'single segment: at least one line group')
47
+ e = decoded[0][0]
48
+ assrt.strictEqual(e.gen_col, 0, 'gen_col 0')
49
+ assrt.strictEqual(e.src_file, 'a.pyj', 'src_file a.pyj')
50
+ assrt.strictEqual(e.src_line, 0, 'src_line 0')
51
+ assrt.strictEqual(e.src_col, 0, 'src_col 0')
52
+
53
+ # Two segments on separate generated lines — deltas accumulate across lines
54
+ smap = JSON.parse(generate_source_map([[0, 0, 'a.pyj', 0, 0], [1, 0, 'a.pyj', 1, 0]], 'out.js', ''))
55
+ decoded = traceback._decode_source_map(smap)
56
+ assrt.ok(decoded.length >= 2, 'two-line map: at least 2 line groups')
57
+ assrt.strictEqual(decoded[1][0].src_line, 1, 'second line resolves to src_line 1')
58
+ assrt.strictEqual(decoded[1][0].src_col, 0, 'second line resolves to src_col 0')
59
+
60
+ # Two segments on same line — gen_col delta is relative within the line
61
+ smap = JSON.parse(generate_source_map([[0, 0, 'a.pyj', 0, 0], [0, 4, 'a.pyj', 0, 3]], 'out.js', ''))
62
+ decoded = traceback._decode_source_map(smap)
63
+ assrt.strictEqual(decoded[0].length, 2, 'two segs on same line → 2 entries')
64
+ assrt.strictEqual(decoded[0][0].gen_col, 0, 'first entry gen_col=0')
65
+ assrt.strictEqual(decoded[0][1].gen_col, 4, 'second entry gen_col=4')
66
+ assrt.strictEqual(decoded[0][1].src_col, 3, 'second entry src_col=3')
67
+
68
+ # Two source files — source index resolves correctly
69
+ smap = JSON.parse(generate_source_map([[0, 0, 'f1.pyj', 0, 0], [0, 10, 'f2.pyj', 0, 0]], 'out.js', ''))
70
+ decoded = traceback._decode_source_map(smap)
71
+ assrt.strictEqual(decoded[0][0].src_file, 'f1.pyj', 'first seg → f1.pyj')
72
+ assrt.strictEqual(decoded[0][1].src_file, 'f2.pyj', 'second seg → f2.pyj')
73
+
74
+ # sourceRoot is prepended to src_file
75
+ smap = JSON.parse(generate_source_map([[0, 0, 'a.pyj', 0, 0]], 'out.js', 'src/'))
76
+ decoded = traceback._decode_source_map(smap)
77
+ assrt.strictEqual(decoded[0][0].src_file, 'src/a.pyj', 'sourceRoot prepended')
78
+
79
+ # Gap in generated lines (line 1 empty) — line group count matches max line
80
+ smap = JSON.parse(generate_source_map([[0, 0, 'a.pyj', 0, 0], [2, 0, 'a.pyj', 2, 0]], 'out.js', ''))
81
+ decoded = traceback._decode_source_map(smap)
82
+ assrt.ok(decoded.length >= 3, 'gap: at least 3 groups for lines 0..2')
83
+ assrt.strictEqual(decoded[1].length, 0, 'gap: line 1 group is empty')
84
+ assrt.strictEqual(decoded[2][0].src_line, 2, 'line 2 resolves to src_line 2')
85
+
86
+ # ---------------------------------------------------------------------------
87
+ # _find_mapping
88
+ # ---------------------------------------------------------------------------
89
+
90
+ # Build a decoded map with two entries on line 0 and one on line 1
91
+ smap = JSON.parse(generate_source_map(
92
+ [[0, 0, 'a.pyj', 10, 0], [0, 5, 'a.pyj', 10, 4], [1, 0, 'a.pyj', 20, 0]], 'out.js', ''))
93
+ decoded = traceback._decode_source_map(smap)
94
+
95
+ # Exact gen_col match
96
+ m = traceback._find_mapping(decoded, 0, 0)
97
+ assrt.ok(m, 'exact match gen_col=0')
98
+ assrt.strictEqual(m.src_line, 10, 'src_line 10 for gen_col=0')
99
+ assrt.strictEqual(m.src_col, 0, 'src_col 0 for gen_col=0')
100
+
101
+ # Between two entries — picks the one with largest gen_col not exceeding target
102
+ m = traceback._find_mapping(decoded, 0, 7)
103
+ assrt.ok(m, 'match between entries (gen_col=7)')
104
+ assrt.strictEqual(m.gen_col, 5, 'picks gen_col=5 for target 7')
105
+ assrt.strictEqual(m.src_col, 4, 'src_col 4 for that entry')
106
+
107
+ # Target gen_col before all entries on the line → no match
108
+ m = traceback._find_mapping(decoded, 0, -1)
109
+ assrt.ok(not m, 'gen_col before first entry → None')
110
+
111
+ # Line with a single entry
112
+ m = traceback._find_mapping(decoded, 1, 0)
113
+ assrt.ok(m, 'single entry on line 1 found')
114
+ assrt.strictEqual(m.src_line, 20, 'src_line 20 on line 1')
115
+
116
+ # gen_line beyond decoded range → None
117
+ assrt.ok(not traceback._find_mapping(decoded, 99, 0), 'out-of-range gen_line → None')
118
+
119
+ # Null/empty decoded → None
120
+ assrt.ok(not traceback._find_mapping(None, 0, 0), 'null decoded → None')
121
+ assrt.ok(not traceback._find_mapping(v'[]', 0, 0), 'empty decoded → None')
122
+
123
+ # Line group with no entries → None
124
+ smap2 = JSON.parse(generate_source_map([[2, 0, 'a.pyj', 5, 0]], 'out.js', ''))
125
+ decoded2 = traceback._decode_source_map(smap2)
126
+ assrt.ok(not traceback._find_mapping(decoded2, 0, 0), 'empty line group → None')
127
+ assrt.ok(traceback._find_mapping(decoded2, 2, 0), 'populated line group → found')
128
+
129
+ # ---------------------------------------------------------------------------
130
+ # _parse_stack_frame
131
+ # ---------------------------------------------------------------------------
132
+
133
+ # Chrome/Node with function name in parentheses
134
+ f = traceback._parse_stack_frame(' at foo (script.js:10:5)')
135
+ assrt.ok(f, 'parsed Chrome frame with function name')
136
+ assrt.strictEqual(f.file, 'script.js', 'file from Chrome frame')
137
+ assrt.strictEqual(f.line, 9, 'line is 0-based (10 → 9)')
138
+ assrt.strictEqual(f.col, 4, 'col is 0-based (5 → 4)')
139
+ assrt.strictEqual(f.func, 'foo', 'func captured for named Chrome frame')
140
+
141
+ # Chrome/Node with method name (dot notation)
142
+ f = traceback._parse_stack_frame(' at Object.bar (app.js:3:1)')
143
+ assrt.ok(f, 'parsed Chrome frame with method name')
144
+ assrt.strictEqual(f.file, 'app.js', 'file from method Chrome frame')
145
+ assrt.strictEqual(f.line, 2, 'line 0-based')
146
+ assrt.strictEqual(f.col, 0, 'col 0-based')
147
+ assrt.strictEqual(f.func, 'Object.bar', 'func captured for method Chrome frame')
148
+
149
+ # Chrome/Node bare (no function name)
150
+ f = traceback._parse_stack_frame(' at script.js:3:1')
151
+ assrt.ok(f, 'parsed bare Chrome frame')
152
+ assrt.strictEqual(f.file, 'script.js', 'file from bare frame')
153
+ assrt.strictEqual(f.line, 2, 'line 0-based from bare frame')
154
+ assrt.strictEqual(f.col, 0, 'col 0-based from bare frame')
155
+ assrt.ok(not f.func, 'func is falsy for bare Chrome frame')
156
+
157
+ # Firefox format with function name
158
+ f = traceback._parse_stack_frame('bar@script.js:7:12')
159
+ assrt.ok(f, 'parsed Firefox frame with function name')
160
+ assrt.strictEqual(f.file, 'script.js', 'file from Firefox frame')
161
+ assrt.strictEqual(f.line, 6, 'line 0-based from Firefox frame')
162
+ assrt.strictEqual(f.col, 11, 'col 0-based from Firefox frame')
163
+ assrt.strictEqual(f.func, 'bar', 'func captured for Firefox frame')
164
+
165
+ # Firefox anonymous frame
166
+ f = traceback._parse_stack_frame('@/path/to/file.js:1:1')
167
+ assrt.ok(f, 'parsed anonymous Firefox frame')
168
+ assrt.strictEqual(f.line, 0, 'line 0-based anonymous Firefox')
169
+ assrt.strictEqual(f.col, 0, 'col 0-based anonymous Firefox')
170
+ assrt.ok(not f.func, 'func is falsy for anonymous Firefox frame')
171
+
172
+ # Lines that are not stack frames → None
173
+ assrt.ok(not traceback._parse_stack_frame('TypeError: some error'), 'error message → None')
174
+ assrt.ok(not traceback._parse_stack_frame(''), 'empty string → None')
175
+ assrt.ok(not traceback._parse_stack_frame('Traceback (most recent call last):'), 'header line → None')
176
+
177
+ # ---------------------------------------------------------------------------
178
+ # set_source_map_data / _get_decoded_map
179
+ # ---------------------------------------------------------------------------
180
+
181
+ # Clearing the source map
182
+ traceback.set_source_map_data(None)
183
+ assrt.ok(not traceback._get_decoded_map(), '_get_decoded_map returns falsy when no map set')
184
+
185
+ # Setting a source map makes _get_decoded_map return a decoded object
186
+ smap = JSON.parse(generate_source_map([[0, 0, 'test.pyj', 0, 0]], 'out.js', ''))
187
+ traceback.set_source_map_data(smap)
188
+ d1 = traceback._get_decoded_map()
189
+ assrt.ok(d1, '_get_decoded_map returns decoded map after set')
190
+
191
+ # Second call returns the same cached object
192
+ d2 = traceback._get_decoded_map()
193
+ assrt.ok(d1 is d2, '_get_decoded_map returns cached object on second call')
194
+
195
+ # Re-setting the map invalidates the cache and produces a new decoded object
196
+ traceback.set_source_map_data(smap)
197
+ d3 = traceback._get_decoded_map()
198
+ assrt.ok(d1 is not d3, 're-setting map clears cache and re-decodes')
199
+
200
+ # ---------------------------------------------------------------------------
201
+ # _get_internal_traceback — end-to-end with mock error
202
+ #
203
+ # We use a hand-crafted error.stack that represents a V8 stack trace for
204
+ # a fictional "demo.js" file, paired with a source map that maps those
205
+ # generated positions back to "demo.pyj" source lines. This lets us
206
+ # test the full mapping pipeline without needing to eval compiled code.
207
+ #
208
+ # ---------------------------------------------------------------------------
209
+
210
+ # Source map: two user-code frames mapped to known pyj source positions.
211
+ # demo.js:5:3 (gen_line=4, gen_col=2) → demo.pyj line 2 col 5 (src_line=1, src_col=4)
212
+ # demo.js:10:1 (gen_line=9, gen_col=0) → demo.pyj line 5 col 1 (src_line=4, src_col=0)
213
+ segments = [
214
+ [4, 0, 'demo.pyj', 1, 0],
215
+ [4, 2, 'demo.pyj', 1, 4],
216
+ [9, 0, 'demo.pyj', 4, 0],
217
+ ]
218
+ smap = JSON.parse(generate_source_map(segments, 'demo.js', ''))
219
+ traceback.set_source_map_data(smap)
220
+
221
+ mock_err = new Error('test error')
222
+ mock_err.name = 'TestError'
223
+ mock_err.stack = (
224
+ 'TestError: test error\n'
225
+ ' at new TestError (baselib.js:1:1)\n'
226
+ ' at throw_me (demo.js:5:3)\n'
227
+ ' at demo.js:10:1'
228
+ )
229
+
230
+ e, js_lines, mapped_lines = traceback._get_internal_traceback(mock_err)
231
+
232
+ # The error message is the first return value
233
+ assrt.strictEqual(e, 'TestError: test error', 'error message returned as first element')
234
+
235
+ # The JS frames array must include the user frame
236
+ assrt.ok(js_lines.join('\n').indexOf('throw_me') >= 0, 'JS section: user frame present')
237
+
238
+ # Mapped lines are returned as an array with entries for each mapped frame
239
+ assrt.ok(mapped_lines.length > 0, 'mapped lines returned when source map is set')
240
+ assrt.ok(mapped_lines.join('\n').indexOf('demo.pyj') >= 0, 'mapped frames reference demo.pyj')
241
+
242
+ # Frame at demo.js:5:3 → gen_line=4, gen_col=2 → src_line=1 → displayed as line 2
243
+ assrt.ok(mapped_lines.join('\n').indexOf('"demo.pyj", line 2') >= 0, 'first frame maps to line 2 in demo.pyj')
244
+
245
+ # Frame at demo.js:10:1 → gen_line=9, gen_col=0 → src_line=4 → displayed as line 5
246
+ assrt.ok(mapped_lines.join('\n').indexOf('"demo.pyj", line 5') >= 0, 'second frame maps to line 5 in demo.pyj')
247
+
248
+ # Named frame includes "in <funcname>" in the mapped output
249
+ assrt.ok(mapped_lines.join('\n').indexOf('in throw_me') >= 0, 'function name "throw_me" included in mapped frame')
250
+
251
+ # Bare frame (no function name) does not produce a spurious "in" clause
252
+ bare_mapped = v'[]'
253
+ for ml in mapped_lines:
254
+ if ml.indexOf('line 5') >= 0:
255
+ bare_mapped.push(ml)
256
+ assrt.ok(bare_mapped.length > 0, 'bare frame entry found')
257
+ assrt.ok(bare_mapped[0].indexOf(', in ') < 0, 'bare frame has no "in" clause')
258
+
259
+ # When source map is cleared, mapped_lines is empty
260
+ traceback.set_source_map_data(None)
261
+ e_no_map, js_lines_no_map, mapped_lines_no_map = traceback._get_internal_traceback(mock_err)
262
+ assrt.strictEqual(mapped_lines_no_map.length, 0, 'no mapped lines without source map')
263
+ assrt.strictEqual(e_no_map, 'TestError: test error', 'JS error message still returned without map')
@@ -0,0 +1,181 @@
1
+ # vim:fileencoding=utf-8
2
+ # globals: test_path, compiler_dir, assrt
3
+
4
+ path = require('path')
5
+ vm = require('vm')
6
+
7
+ tree_shake = require('./treeshake.mjs').default
8
+ baselib = fs.readFileSync(path.join(compiler_dir, 'baselib-plain-pretty.js'), 'utf-8')
9
+ src_lib = path.join(compiler_dir, '..', 'src', 'lib')
10
+
11
+ eq = assrt.equal
12
+ ok = assrt.ok
13
+
14
+ def new_output_stream(opts):
15
+ return v'new RapydScript.OutputStream(opts)'
16
+
17
+ def make_output_stream(omit_baselib):
18
+ opts = {
19
+ 'js_version': 6,
20
+ 'beautify': False,
21
+ 'private_scope': False,
22
+ 'write_name': False,
23
+ 'keep_docstrings': False,
24
+ }
25
+ if omit_baselib:
26
+ opts['omit_baselib'] = True
27
+ else:
28
+ opts['baselib_plain'] = baselib
29
+ return new_output_stream(opts)
30
+
31
+ async def parse_code(code, extra_opts):
32
+ opts = {
33
+ 'filename': '<test>',
34
+ 'basedir': test_path,
35
+ 'libdir': src_lib,
36
+ }
37
+ if extra_opts:
38
+ for k in extra_opts:
39
+ opts[k] = extra_opts[k]
40
+ return await RapydScript.parse(code, opts)
41
+
42
+ def js_of(ast, omit_baselib):
43
+ output = make_output_stream(omit_baselib)
44
+ ast.print(output)
45
+ return output.get()
46
+
47
+ async def compile_shaken(code, extra_opts):
48
+ ast = await parse_code(code, extra_opts)
49
+ tree_shake(ast)
50
+ return js_of(ast, True)
51
+
52
+ async def run_shaken(code, extra_opts):
53
+ ast = await parse_code(code, extra_opts)
54
+ tree_shake(ast)
55
+ output = make_output_stream(False)
56
+ ast.print(output)
57
+ js = output.get()
58
+ sandbox = {'console': console}
59
+ vm.runInNewContext(js, sandbox, {'filename': '<treeshake-test>'})
60
+ return sandbox
61
+
62
+ async def run_tests():
63
+ # ── dead top-level function removed ──────────────────────────────────────────
64
+ js = await compile_shaken('def live():\n return 1\ndef dead():\n return 2\nx = live()')
65
+ ok(js.indexOf('function dead') < 0, 'dead function must be removed')
66
+ ok(js.indexOf('function live') >= 0, 'live function must be kept')
67
+
68
+ # ── dead top-level class removed ─────────────────────────────────────────────
69
+ js = await compile_shaken('class Live:\n def __init__(self):\n self.x = 1\nclass Dead:\n def __init__(self):\n self.y = 2\nu = Live()')
70
+ ok(js.indexOf('function Dead') < 0, 'dead class must be removed')
71
+ ok(js.indexOf('function Live') >= 0, 'live class must be kept')
72
+
73
+ # ── transitively dead code removed ───────────────────────────────────────────
74
+ js = await compile_shaken('def helper():\n return 42\ndef dead_caller():\n return helper()\ndef live():\n return 1\nx = live()')
75
+ ok(js.indexOf('function helper') < 0, 'transitively dead helper must be removed')
76
+ ok(js.indexOf('function dead_caller') < 0, 'dead caller must be removed')
77
+ ok(js.indexOf('function live') >= 0, 'live function must be kept')
78
+
79
+ # ── transitively live code preserved ─────────────────────────────────────────
80
+ js = await compile_shaken('def helper():\n return 42\ndef live_caller():\n return helper()\nx = live_caller()')
81
+ ok(js.indexOf('function helper') >= 0, 'transitively live helper must be kept')
82
+ ok(js.indexOf('function live_caller') >= 0, 'live caller must be kept')
83
+
84
+ # ── circular references: both live ───────────────────────────────────────────
85
+ js = await compile_shaken('def a():\n return b()\ndef b():\n return a()\na()')
86
+ ok(js.indexOf('function a') >= 0, 'circular-dep a must be kept')
87
+ ok(js.indexOf('function b') >= 0, 'circular-dep b must be kept')
88
+
89
+ # ── function used as value stays live ─────────────────────────────────────────
90
+ js = await compile_shaken('def foo():\n return 1\ncallback = foo')
91
+ ok(js.indexOf('function foo') >= 0, 'function used as value must be kept')
92
+
93
+ # ── class used as base class stays live ──────────────────────────────────────
94
+ js = await compile_shaken('class Base:\n def method(self):\n return 1\nclass Derived(Base):\n pass\nx = Derived()')
95
+ ok(js.indexOf('function Base') >= 0, 'base class must be kept')
96
+ ok(js.indexOf('function Derived') >= 0, 'derived class must be kept')
97
+
98
+ # ── decorator marks function as live ─────────────────────────────────────────
99
+ js = await compile_shaken('def my_dec(f):\n return f\n@my_dec\ndef foo():\n return 1\nfoo()')
100
+ ok(js.indexOf('function my_dec') >= 0, 'decorator must be kept when decorated function is live')
101
+
102
+ # ── completely unused module import removed ───────────────────────────────────
103
+ js = await compile_shaken('from _treeshake_mod import func_used\nx = 1')
104
+ ok(js.indexOf('_treeshake_mod') < 0, 'unused module must be removed from output')
105
+ ok(js.indexOf('ρσ_modules') < 0 or js.indexOf('_treeshake_mod') < 0, 'module IIFE must not be emitted')
106
+
107
+ # ── module used only in dead code is removed ─────────────────────────────────
108
+ js = await compile_shaken('from _treeshake_mod import func_used\ndef dead():\n return func_used()\nx = 1')
109
+ ok(js.indexOf('_treeshake_mod') < 0, 'module only referenced from dead code must be removed')
110
+
111
+ # ── partially used module: only used function imported ───────────────────────
112
+ js = await compile_shaken('from _treeshake_mod import func_used, func_unused\nx = func_used()')
113
+ ok(js.indexOf('func_unused') < 0, 'unused import binding must be removed')
114
+ ok(js.indexOf('func_used') >= 0, 'used import binding must be kept')
115
+
116
+ # ── used module kept, unused function in it removed ──────────────────────────
117
+ js = await compile_shaken('from _treeshake_mod import func_used\nx = func_used()')
118
+ ok(js.indexOf('func_unused') < 0, 'unused function in used module must be removed')
119
+ ok(js.indexOf('module_var') >= 0, 'module variable (non-def) must always be kept')
120
+
121
+ # ── transitive liveness: helper_b calls helper_a ─────────────────────────────
122
+ js = await compile_shaken('from _treeshake_mod import helper_b\nx = helper_b()')
123
+ ok(js.indexOf('helper_a') >= 0, 'transitively needed helper_a must be kept')
124
+ ok(js.indexOf('helper_b') >= 0, 'directly imported helper_b must be kept')
125
+ ok(js.indexOf('func_unused') < 0, 'unrelated func_unused must be removed')
126
+
127
+ # ── stdlib module removed when unused ────────────────────────────────────────
128
+ js = await compile_shaken('from math import sin\ndef dead():\n return sin(1.0)\nx = 1')
129
+ ok(js.indexOf('math') < 0 or js.indexOf('ρσ_modules.math') < 0,
130
+ 'stdlib module only used from dead code must be removed')
131
+
132
+ # ── stdlib: only used function kept (when module not cached) ─────────────────
133
+ js = await compile_shaken('from math import sin\nx = sin(1.0)')
134
+ ok(js.indexOf('sin') >= 0, 'used stdlib function must be present')
135
+
136
+ # ── namespace import: module without side-effect calls removed when unused ────
137
+ # _treeshake_mod has only a simple scalar assignment at module level (no calls)
138
+ js = await compile_shaken('import _treeshake_mod\nx = 1')
139
+ ok(js.indexOf('_treeshake_mod') < 0, 'namespace import of module with no side-effect calls must be removed when unused')
140
+
141
+ # ── namespace import: module kept when namespace is used ─────────────────────
142
+ js = await compile_shaken('import _treeshake_mod\nx = _treeshake_mod.func_used()')
143
+ ok(js.indexOf('_treeshake_mod') >= 0, 'namespace import of used module must be kept')
144
+
145
+ # ── side-effect namespace import: module with module-level call must be kept ──
146
+ # Mirrors calibre's "import initialize" pattern where the module patches a global
147
+ js = await compile_shaken('import _treeshake_side_effect\nx = 1')
148
+ ok(js.indexOf('_treeshake_side_effect') >= 0, 'namespace import of module with side-effect calls must not be removed')
149
+
150
+ # ── side-effect namespace import: the side effect must actually run ───────────
151
+ ctx = await run_shaken('import _treeshake_side_effect\nresult = "x".__treeshake_test()')
152
+ eq(ctx['result'], 'side_effect_ran', 'side-effect namespace import must execute module-level calls')
153
+
154
+ # ── behavior: tree-shaken code runs correctly ─────────────────────────────────
155
+ ctx = await run_shaken('def live():\n return 42\ndef dead():\n return 99\nresult = live()')
156
+ eq(ctx['result'], 42, 'tree-shaken code must return correct result')
157
+ ok(not (ctx['dead'] if ctx['dead'] else False), 'dead function must not be defined after tree shaking')
158
+
159
+ # ── behavior: class instantiation survives tree shaking ──────────────────────
160
+ ctx = await run_shaken('class MyClass:\n def __init__(self, v):\n self.v = v\nobj = MyClass(7)')
161
+ eq(ctx['obj'].v, 7, 'instantiated object must have correct attribute')
162
+
163
+ # ── behavior: decorator works after tree shaking ─────────────────────────────
164
+ ctx = await run_shaken('def identity(f):\n return f\n@identity\ndef compute():\n return 55\nresult = compute()')
165
+ eq(ctx['result'], 55, 'decorated function must work correctly after tree shaking')
166
+
167
+ # ── variable at top level not removed by tree shaking ────────────────────────
168
+ ctx = await run_shaken('my_var = 123\ndef unused():\n return 0')
169
+ eq(ctx['my_var'], 123, 'top-level variable must not be removed')
170
+
171
+ # ── @no_prune: function never called directly is preserved ────────────────────
172
+ js = await compile_shaken('@no_prune\ndef pinned():\n return 1\ndef unpinned():\n return 2\nx = 1')
173
+ ok(js.indexOf('function pinned') >= 0, '@no_prune function must not be pruned')
174
+ ok(js.indexOf('function unpinned') < 0, 'non-annotated unused function must still be pruned')
175
+
176
+ # ── @no_prune: class never referenced is preserved ───────────────────────────
177
+ js = await compile_shaken('@no_prune\nclass PinnedClass:\n def __init__(self):\n self.x = 1\nclass UnpinnedClass:\n def __init__(self):\n self.y = 2\nx = 1')
178
+ ok(js.indexOf('function PinnedClass') >= 0, '@no_prune class must not be pruned')
179
+ ok(js.indexOf('function UnpinnedClass') < 0, 'non-annotated unused class must still be pruned')
180
+
181
+ __test_async_done__ = run_tests()
@@ -0,0 +1,88 @@
1
+ # globals: assrt, fs, require, test_path, compiler_dir, console
2
+
3
+ async def run_tests():
4
+ os = require('os')
5
+ path = require('path')
6
+ vm = require('vm')
7
+
8
+ run_export = require('./web_repl_export.mjs').default
9
+ base_path = path.resolve(compiler_dir, '..')
10
+ lib_path = compiler_dir
11
+
12
+ tmpdir = fs.mkdtempSync(path.join(os.tmpdir(), 'rs-web-repl-test-'))
13
+ await run_export(base_path, lib_path, {'files': [tmpdir]})
14
+
15
+ rs_path = path.join(tmpdir, 'rapydscript.js')
16
+ assrt.ok(fs.existsSync(rs_path), 'web-repl-export must create rapydscript.js')
17
+
18
+ # Load rapydscript.js into a vm context; the IIFE does (function(external_namespace){...})(this)
19
+ # so this === ctx and ctx.RapydScript is set to the namespace object
20
+ rs_code = fs.readFileSync(rs_path, 'utf-8')
21
+ ctx = {'console': console}
22
+ vm.runInNewContext(rs_code, ctx)
23
+ RS = ctx.RapydScript
24
+
25
+ assrt.ok(RS, 'RapydScript namespace must be defined after loading rapydscript.js')
26
+ assrt.ok(RS.compile, 'compile must be defined on the namespace')
27
+ assrt.ok(RS.create_embedded_compiler, 'create_embedded_compiler must be defined on the namespace')
28
+ assrt.ok(RS.file_data, 'file_data must be present on the namespace')
29
+
30
+ # Verify basic compilation works
31
+ js = await RS.compile('x = 1 + 2\n', 'test.pyj', {})
32
+ assrt.ok(js and js.length > 0, 'compile must return non-empty JS for basic code')
33
+
34
+ # Set up a virtual file system that records stat and write calls so each hook
35
+ # can be verified independently. Cache path for __vfs__/mymod.pyj is
36
+ # __vfs__/mymod.pyj-cached (cache_file_name with empty module_cache_dir).
37
+ vfs_source = 'def greet(name):\n return "Hello, " + name\n'
38
+ vfs_written = {}
39
+ stat_calls = []
40
+ write_calls = []
41
+
42
+ def vfs_read(vfs_path, enc):
43
+ if vfs_path == '__vfs__/mymod.pyj':
44
+ return Promise.resolve(vfs_source)
45
+ err = new Error('ENOENT: ' + vfs_path)
46
+ err.code = 'ENOENT'
47
+ return Promise.reject(err)
48
+
49
+ def vfs_write(vfs_path, data):
50
+ write_calls.push(vfs_path)
51
+ vfs_written[vfs_path] = data
52
+ return Promise.resolve()
53
+
54
+ def vfs_stat(vfs_path):
55
+ stat_calls.push(vfs_path)
56
+ if vfs_path == '__vfs__/mymod.pyj':
57
+ # Return content so the compiler avoids a separate readfile call
58
+ return Promise.resolve({'mtimeMs': 1000, 'content': vfs_source})
59
+ err = new Error('ENOENT: ' + vfs_path)
60
+ err.code = 'ENOENT'
61
+ return Promise.reject(err)
62
+
63
+ RS.virtual_file_system = {
64
+ 'read_file': vfs_read,
65
+ 'write_file': vfs_write,
66
+ 'stat_file': vfs_stat,
67
+ }
68
+
69
+ # create_embedded_compiler sets basedir=__vfs__ and libdir=__stdlib__, routing
70
+ # all user imports through VFS
71
+ embedded = await RS.create_embedded_compiler()
72
+ js2 = await embedded.compile('from mymod import greet\nresult = greet("World")\n')
73
+ assrt.ok(js2 and js2.length > 0, 'embedded compiler with VFS must return non-empty JS')
74
+ assrt.ok(js2.indexOf('greet') >= 0, 'compiled output must reference the function imported via VFS')
75
+
76
+ # stat_file must be called when the compiler resolves the VFS module path
77
+ assrt.ok(stat_calls.length > 0, 'stat_file must be called during compilation of VFS imports')
78
+ assrt.ok(stat_calls.indexOf('__vfs__/mymod.pyj') >= 0,
79
+ 'stat_file must be called with the imported module path')
80
+
81
+ # write_file must be called when the compiler writes the parsed-AST cache
82
+ assrt.ok(write_calls.length > 0, 'write_file must be called to persist the compiled VFS module cache')
83
+ assrt.ok(write_calls.indexOf('__vfs__/mymod.pyj-cached') >= 0,
84
+ 'write_file must be called with the cache path derived from the VFS module')
85
+
86
+ fs.rmSync(tmpdir, {'recursive': True})
87
+
88
+ __test_async_done__ = run_tests()