pulse-framework 0.1.46__py3-none-any.whl → 0.1.47__py3-none-any.whl
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.
- pulse/__init__.py +9 -23
- pulse/app.py +2 -24
- pulse/codegen/codegen.py +43 -88
- pulse/codegen/js.py +35 -5
- pulse/codegen/templates/route.py +341 -254
- pulse/form.py +1 -1
- pulse/helpers.py +40 -8
- pulse/hooks/core.py +2 -2
- pulse/hooks/effects.py +1 -1
- pulse/hooks/init.py +2 -1
- pulse/hooks/setup.py +1 -1
- pulse/hooks/stable.py +2 -2
- pulse/hooks/states.py +2 -2
- pulse/html/props.py +3 -2
- pulse/html/tags.py +135 -0
- pulse/html/tags.pyi +4 -0
- pulse/js/__init__.py +110 -0
- pulse/js/__init__.pyi +95 -0
- pulse/js/_types.py +297 -0
- pulse/js/array.py +253 -0
- pulse/js/console.py +47 -0
- pulse/js/date.py +113 -0
- pulse/js/document.py +138 -0
- pulse/js/error.py +139 -0
- pulse/js/json.py +62 -0
- pulse/js/map.py +84 -0
- pulse/js/math.py +66 -0
- pulse/js/navigator.py +76 -0
- pulse/js/number.py +54 -0
- pulse/js/object.py +173 -0
- pulse/js/promise.py +150 -0
- pulse/js/regexp.py +54 -0
- pulse/js/set.py +109 -0
- pulse/js/string.py +35 -0
- pulse/js/weakmap.py +50 -0
- pulse/js/weakset.py +45 -0
- pulse/js/window.py +199 -0
- pulse/messages.py +22 -3
- pulse/react_component.py +167 -14
- pulse/reactive_extensions.py +5 -5
- pulse/render_session.py +144 -34
- pulse/renderer.py +80 -115
- pulse/routing.py +1 -18
- pulse/transpiler/__init__.py +131 -0
- pulse/transpiler/builtins.py +731 -0
- pulse/transpiler/constants.py +110 -0
- pulse/transpiler/context.py +26 -0
- pulse/transpiler/errors.py +2 -0
- pulse/transpiler/function.py +250 -0
- pulse/transpiler/ids.py +16 -0
- pulse/transpiler/imports.py +409 -0
- pulse/transpiler/js_module.py +274 -0
- pulse/transpiler/modules/__init__.py +30 -0
- pulse/transpiler/modules/asyncio.py +38 -0
- pulse/transpiler/modules/json.py +20 -0
- pulse/transpiler/modules/math.py +320 -0
- pulse/transpiler/modules/re.py +466 -0
- pulse/transpiler/modules/tags.py +268 -0
- pulse/transpiler/modules/typing.py +59 -0
- pulse/transpiler/nodes.py +1216 -0
- pulse/transpiler/py_module.py +119 -0
- pulse/transpiler/transpiler.py +938 -0
- pulse/transpiler/utils.py +4 -0
- pulse/vdom.py +112 -6
- {pulse_framework-0.1.46.dist-info → pulse_framework-0.1.47.dist-info}/METADATA +1 -1
- pulse_framework-0.1.47.dist-info/RECORD +119 -0
- pulse/codegen/imports.py +0 -204
- pulse/css.py +0 -155
- pulse_framework-0.1.46.dist-info/RECORD +0 -80
- {pulse_framework-0.1.46.dist-info → pulse_framework-0.1.47.dist-info}/WHEEL +0 -0
- {pulse_framework-0.1.46.dist-info → pulse_framework-0.1.47.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,938 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Python -> JavaScript transpiler for pure functions.
|
|
3
|
+
|
|
4
|
+
Transpiles a restricted subset of Python into JavaScript. Handles:
|
|
5
|
+
- Pure functions (no global state mutation)
|
|
6
|
+
- Python syntax -> JS syntax conversion
|
|
7
|
+
- Python builtin functions -> JS equivalents
|
|
8
|
+
- Python builtin methods (str, list, dict, set) -> JS equivalents
|
|
9
|
+
|
|
10
|
+
This transpiler is designed for use with @javascript decorated functions.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import ast
|
|
16
|
+
import re
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from pulse.transpiler.errors import JSCompilationError
|
|
21
|
+
from pulse.transpiler.nodes import (
|
|
22
|
+
ALLOWED_BINOPS,
|
|
23
|
+
ALLOWED_CMPOPS,
|
|
24
|
+
ALLOWED_UNOPS,
|
|
25
|
+
JSArray,
|
|
26
|
+
JSArrowFunction,
|
|
27
|
+
JSAssign,
|
|
28
|
+
JSAugAssign,
|
|
29
|
+
JSAwait,
|
|
30
|
+
JSBinary,
|
|
31
|
+
JSBoolean,
|
|
32
|
+
JSBreak,
|
|
33
|
+
JSCall,
|
|
34
|
+
JSConstAssign,
|
|
35
|
+
JSContinue,
|
|
36
|
+
JSExpr,
|
|
37
|
+
JSForOf,
|
|
38
|
+
JSFunctionDef,
|
|
39
|
+
JSIdentifier,
|
|
40
|
+
JSIf,
|
|
41
|
+
JSLogicalChain,
|
|
42
|
+
JSMember,
|
|
43
|
+
JSMemberCall,
|
|
44
|
+
JSMultiStmt,
|
|
45
|
+
JSNew,
|
|
46
|
+
JSNull,
|
|
47
|
+
JSNumber,
|
|
48
|
+
JSReturn,
|
|
49
|
+
JSSingleStmt,
|
|
50
|
+
JSSpread,
|
|
51
|
+
JSStmt,
|
|
52
|
+
JSStmtExpr,
|
|
53
|
+
JSString,
|
|
54
|
+
JSSubscript,
|
|
55
|
+
JSTemplate,
|
|
56
|
+
JSTertiary,
|
|
57
|
+
JSUnary,
|
|
58
|
+
JSUndefined,
|
|
59
|
+
JSWhile,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class JsTranspiler(ast.NodeVisitor):
|
|
64
|
+
"""AST visitor that builds a JS AST from a restricted Python subset.
|
|
65
|
+
|
|
66
|
+
The visitor receives a deps dictionary mapping names to JSExpr values.
|
|
67
|
+
Behavior is encoded in JSExpr subclass hooks (emit_call, emit_getattr, emit_subscript).
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
fndef: ast.FunctionDef | ast.AsyncFunctionDef
|
|
71
|
+
args: list[str]
|
|
72
|
+
deps: dict[str, JSExpr]
|
|
73
|
+
locals: set[str]
|
|
74
|
+
_temp_counter: int
|
|
75
|
+
_is_async: bool
|
|
76
|
+
|
|
77
|
+
def __init__(
|
|
78
|
+
self,
|
|
79
|
+
fndef: ast.FunctionDef | ast.AsyncFunctionDef,
|
|
80
|
+
args: list[str],
|
|
81
|
+
deps: dict[str, JSExpr],
|
|
82
|
+
) -> None:
|
|
83
|
+
self.fndef = fndef
|
|
84
|
+
self.args = args
|
|
85
|
+
self.deps = deps
|
|
86
|
+
# Track locals for declaration decisions (args are predeclared)
|
|
87
|
+
self.locals = set(args)
|
|
88
|
+
self._temp_counter = 0
|
|
89
|
+
# Track async status during transpilation (starts True if source is async def)
|
|
90
|
+
self._is_async = isinstance(fndef, ast.AsyncFunctionDef)
|
|
91
|
+
|
|
92
|
+
def _fresh_temp(self) -> str:
|
|
93
|
+
"""Generate a fresh temporary variable name."""
|
|
94
|
+
name = f"$tmp{self._temp_counter}"
|
|
95
|
+
self._temp_counter += 1
|
|
96
|
+
return name
|
|
97
|
+
|
|
98
|
+
def _is_string_expr(self, expr: JSExpr) -> bool:
|
|
99
|
+
"""Check if an expression is known to produce a string."""
|
|
100
|
+
# Check common patterns that produce strings
|
|
101
|
+
if isinstance(expr, JSString):
|
|
102
|
+
return True
|
|
103
|
+
if isinstance(expr, JSCall) and isinstance(expr.callee, JSIdentifier):
|
|
104
|
+
return expr.callee.name == "String"
|
|
105
|
+
if isinstance(expr, JSMemberCall):
|
|
106
|
+
# Methods that return strings
|
|
107
|
+
return expr.method in (
|
|
108
|
+
"toFixed",
|
|
109
|
+
"toExponential",
|
|
110
|
+
"toString",
|
|
111
|
+
"toUpperCase",
|
|
112
|
+
"toLowerCase",
|
|
113
|
+
"trim",
|
|
114
|
+
"padStart",
|
|
115
|
+
"padEnd",
|
|
116
|
+
)
|
|
117
|
+
return False
|
|
118
|
+
|
|
119
|
+
# --- Entrypoint ---------------------------------------------------------
|
|
120
|
+
def transpile(self, name: str | None = None) -> JSFunctionDef:
|
|
121
|
+
"""Transpile the function definition to a JS function.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
name: Optional function name to emit. If None, emits anonymous function.
|
|
125
|
+
"""
|
|
126
|
+
stmts: list[JSStmt] = []
|
|
127
|
+
self._temp_counter = 0
|
|
128
|
+
for i, stmt in enumerate(self.fndef.body):
|
|
129
|
+
# Skip docstrings (first statement that's a string constant expression)
|
|
130
|
+
if (
|
|
131
|
+
i == 0
|
|
132
|
+
and isinstance(stmt, ast.Expr)
|
|
133
|
+
and isinstance(stmt.value, ast.Constant)
|
|
134
|
+
and isinstance(stmt.value.value, str)
|
|
135
|
+
):
|
|
136
|
+
continue
|
|
137
|
+
s = self.emit_stmt(stmt)
|
|
138
|
+
stmts.append(s)
|
|
139
|
+
# Use the flag we tracked during transpilation
|
|
140
|
+
return JSFunctionDef(self.args, stmts, name=name, is_async=self._is_async)
|
|
141
|
+
|
|
142
|
+
# --- Statements ----------------------------------------------------------
|
|
143
|
+
def emit_stmt(self, node: ast.stmt) -> JSStmt:
|
|
144
|
+
"""Emit a statement."""
|
|
145
|
+
if isinstance(node, ast.Return):
|
|
146
|
+
return JSReturn(self.emit_expr(node.value))
|
|
147
|
+
|
|
148
|
+
if isinstance(node, ast.Break):
|
|
149
|
+
return JSBreak()
|
|
150
|
+
|
|
151
|
+
if isinstance(node, ast.Continue):
|
|
152
|
+
return JSContinue()
|
|
153
|
+
|
|
154
|
+
if isinstance(node, ast.Pass):
|
|
155
|
+
# Pass is a no-op, emit empty statement
|
|
156
|
+
return JSMultiStmt([])
|
|
157
|
+
|
|
158
|
+
if isinstance(node, ast.AugAssign):
|
|
159
|
+
if not isinstance(node.target, ast.Name):
|
|
160
|
+
raise JSCompilationError("Only simple augmented assignments supported")
|
|
161
|
+
target = node.target.id
|
|
162
|
+
op_type = type(node.op)
|
|
163
|
+
if op_type not in ALLOWED_BINOPS:
|
|
164
|
+
raise JSCompilationError(
|
|
165
|
+
f"Unsupported augmented assignment operator: {op_type.__name__}"
|
|
166
|
+
)
|
|
167
|
+
value_expr = self.emit_expr(node.value)
|
|
168
|
+
return JSAugAssign(target, ALLOWED_BINOPS[op_type], value_expr)
|
|
169
|
+
|
|
170
|
+
if isinstance(node, ast.Assign):
|
|
171
|
+
if len(node.targets) != 1:
|
|
172
|
+
raise JSCompilationError(
|
|
173
|
+
"Multiple assignment targets are not supported"
|
|
174
|
+
)
|
|
175
|
+
target_node = node.targets[0]
|
|
176
|
+
|
|
177
|
+
# Tuple/list unpacking
|
|
178
|
+
if isinstance(target_node, (ast.Tuple, ast.List)):
|
|
179
|
+
return self._emit_unpacking_assign(target_node, node.value)
|
|
180
|
+
|
|
181
|
+
if not isinstance(target_node, ast.Name):
|
|
182
|
+
raise JSCompilationError(
|
|
183
|
+
"Only simple assignments to local names are supported"
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
target = target_node.id
|
|
187
|
+
value_expr = self.emit_expr(node.value)
|
|
188
|
+
|
|
189
|
+
if target in self.locals:
|
|
190
|
+
return JSAssign(target, value_expr, declare=False)
|
|
191
|
+
else:
|
|
192
|
+
self.locals.add(target)
|
|
193
|
+
return JSAssign(target, value_expr, declare=True)
|
|
194
|
+
|
|
195
|
+
if isinstance(node, ast.AnnAssign):
|
|
196
|
+
if not isinstance(node.target, ast.Name):
|
|
197
|
+
raise JSCompilationError("Only simple annotated assignments supported")
|
|
198
|
+
target = node.target.id
|
|
199
|
+
value = JSUndefined() if node.value is None else self.emit_expr(node.value)
|
|
200
|
+
if target in self.locals:
|
|
201
|
+
return JSAssign(target, value, declare=False)
|
|
202
|
+
else:
|
|
203
|
+
self.locals.add(target)
|
|
204
|
+
return JSAssign(target, value, declare=True)
|
|
205
|
+
|
|
206
|
+
if isinstance(node, ast.If):
|
|
207
|
+
test = self.emit_expr(node.test)
|
|
208
|
+
body = [self.emit_stmt(s) for s in node.body]
|
|
209
|
+
orelse = [self.emit_stmt(s) for s in node.orelse]
|
|
210
|
+
return JSIf(test, body, orelse)
|
|
211
|
+
|
|
212
|
+
if isinstance(node, ast.Expr):
|
|
213
|
+
expr = self.emit_expr(node.value)
|
|
214
|
+
# Unwrap statement-expressions (e.g., throw)
|
|
215
|
+
if isinstance(expr, JSStmtExpr):
|
|
216
|
+
return expr.stmt
|
|
217
|
+
return JSSingleStmt(expr)
|
|
218
|
+
|
|
219
|
+
if isinstance(node, ast.While):
|
|
220
|
+
test = self.emit_expr(node.test)
|
|
221
|
+
body = [self.emit_stmt(s) for s in node.body]
|
|
222
|
+
return JSWhile(test, body)
|
|
223
|
+
|
|
224
|
+
if isinstance(node, ast.For):
|
|
225
|
+
return self._emit_for_loop(node)
|
|
226
|
+
|
|
227
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
228
|
+
return self._emit_nested_function(node)
|
|
229
|
+
|
|
230
|
+
raise JSCompilationError(f"Unsupported statement: {type(node).__name__}")
|
|
231
|
+
|
|
232
|
+
def _emit_unpacking_assign(
|
|
233
|
+
self, target: ast.Tuple | ast.List, value: ast.expr
|
|
234
|
+
) -> JSStmt:
|
|
235
|
+
"""Emit unpacking assignment: a, b, c = expr"""
|
|
236
|
+
elements = target.elts
|
|
237
|
+
if not elements or not all(isinstance(e, ast.Name) for e in elements):
|
|
238
|
+
raise JSCompilationError("Unpacking is only supported for simple variables")
|
|
239
|
+
|
|
240
|
+
tmp_name = self._fresh_temp()
|
|
241
|
+
value_expr = self.emit_expr(value)
|
|
242
|
+
stmts: list[JSStmt] = [JSConstAssign(tmp_name, value_expr)]
|
|
243
|
+
|
|
244
|
+
for idx, e in enumerate(elements):
|
|
245
|
+
assert isinstance(e, ast.Name)
|
|
246
|
+
name = e.id
|
|
247
|
+
sub = JSSubscript(JSIdentifier(tmp_name), JSNumber(idx))
|
|
248
|
+
if name in self.locals:
|
|
249
|
+
stmts.append(JSAssign(name, sub, declare=False))
|
|
250
|
+
else:
|
|
251
|
+
self.locals.add(name)
|
|
252
|
+
stmts.append(JSAssign(name, sub, declare=True))
|
|
253
|
+
|
|
254
|
+
return JSMultiStmt(stmts)
|
|
255
|
+
|
|
256
|
+
def _emit_for_loop(self, node: ast.For) -> JSStmt:
|
|
257
|
+
"""Emit a for loop."""
|
|
258
|
+
# Handle tuple unpacking in for target
|
|
259
|
+
if isinstance(node.target, (ast.Tuple, ast.List)):
|
|
260
|
+
names: list[str] = []
|
|
261
|
+
for e in node.target.elts:
|
|
262
|
+
if not isinstance(e, ast.Name):
|
|
263
|
+
raise JSCompilationError(
|
|
264
|
+
"Only simple name targets supported in for-loop unpacking"
|
|
265
|
+
)
|
|
266
|
+
names.append(e.id)
|
|
267
|
+
self.locals.add(e.id)
|
|
268
|
+
iter_expr = self.emit_expr(node.iter)
|
|
269
|
+
body = [self.emit_stmt(s) for s in node.body]
|
|
270
|
+
return JSForOf(names, iter_expr, body)
|
|
271
|
+
|
|
272
|
+
if not isinstance(node.target, ast.Name):
|
|
273
|
+
raise JSCompilationError("Only simple name targets supported in for-loops")
|
|
274
|
+
|
|
275
|
+
target = node.target.id
|
|
276
|
+
self.locals.add(target)
|
|
277
|
+
iter_expr = self.emit_expr(node.iter)
|
|
278
|
+
body = [self.emit_stmt(s) for s in node.body]
|
|
279
|
+
return JSForOf(target, iter_expr, body)
|
|
280
|
+
|
|
281
|
+
def _emit_nested_function(
|
|
282
|
+
self, node: ast.FunctionDef | ast.AsyncFunctionDef
|
|
283
|
+
) -> JSStmt:
|
|
284
|
+
"""Emit a nested function definition."""
|
|
285
|
+
name = node.name
|
|
286
|
+
params = [arg.arg for arg in node.args.args]
|
|
287
|
+
|
|
288
|
+
# Save current locals and extend with params (closure captures outer scope)
|
|
289
|
+
saved_locals = set(self.locals)
|
|
290
|
+
self.locals.update(params)
|
|
291
|
+
|
|
292
|
+
# Skip docstrings and emit body
|
|
293
|
+
stmts: list[JSStmt] = []
|
|
294
|
+
for i, stmt in enumerate(node.body):
|
|
295
|
+
if (
|
|
296
|
+
i == 0
|
|
297
|
+
and isinstance(stmt, ast.Expr)
|
|
298
|
+
and isinstance(stmt.value, ast.Constant)
|
|
299
|
+
and isinstance(stmt.value.value, str)
|
|
300
|
+
):
|
|
301
|
+
continue
|
|
302
|
+
stmts.append(self.emit_stmt(stmt))
|
|
303
|
+
|
|
304
|
+
# Restore outer locals and add function name
|
|
305
|
+
self.locals = saved_locals
|
|
306
|
+
self.locals.add(name)
|
|
307
|
+
|
|
308
|
+
is_async = isinstance(node, ast.AsyncFunctionDef)
|
|
309
|
+
fn = JSFunctionDef(params, stmts, name=None, is_async=is_async)
|
|
310
|
+
return JSConstAssign(name, fn)
|
|
311
|
+
|
|
312
|
+
# --- Expressions ---------------------------------------------------------
|
|
313
|
+
def emit_expr(self, node: ast.expr | None) -> JSExpr:
|
|
314
|
+
"""Emit an expression."""
|
|
315
|
+
if node is None:
|
|
316
|
+
return JSNull()
|
|
317
|
+
|
|
318
|
+
if isinstance(node, ast.Constant):
|
|
319
|
+
return self._emit_constant(node)
|
|
320
|
+
|
|
321
|
+
if isinstance(node, ast.Name):
|
|
322
|
+
return self._emit_name(node)
|
|
323
|
+
|
|
324
|
+
if isinstance(node, (ast.List, ast.Tuple)):
|
|
325
|
+
return self._emit_list_or_tuple(node)
|
|
326
|
+
|
|
327
|
+
if isinstance(node, ast.Dict):
|
|
328
|
+
return self._emit_dict(node)
|
|
329
|
+
|
|
330
|
+
if isinstance(node, ast.Set):
|
|
331
|
+
return JSNew(
|
|
332
|
+
JSIdentifier("Set"), [JSArray([self.emit_expr(e) for e in node.elts])]
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
if isinstance(node, ast.BinOp):
|
|
336
|
+
return self._emit_binop(node)
|
|
337
|
+
|
|
338
|
+
if isinstance(node, ast.UnaryOp):
|
|
339
|
+
return self._emit_unaryop(node)
|
|
340
|
+
|
|
341
|
+
if isinstance(node, ast.BoolOp):
|
|
342
|
+
op = "&&" if isinstance(node.op, ast.And) else "||"
|
|
343
|
+
return JSLogicalChain(op, [self.emit_expr(v) for v in node.values])
|
|
344
|
+
|
|
345
|
+
if isinstance(node, ast.Compare):
|
|
346
|
+
return self._emit_compare(node)
|
|
347
|
+
|
|
348
|
+
if isinstance(node, ast.IfExp):
|
|
349
|
+
test = self.emit_expr(node.test)
|
|
350
|
+
body = self.emit_expr(node.body)
|
|
351
|
+
orelse = self.emit_expr(node.orelse)
|
|
352
|
+
return JSTertiary(test, body, orelse)
|
|
353
|
+
|
|
354
|
+
if isinstance(node, ast.Call):
|
|
355
|
+
return self._emit_call(node)
|
|
356
|
+
|
|
357
|
+
if isinstance(node, ast.Attribute):
|
|
358
|
+
return self._emit_attribute(node)
|
|
359
|
+
|
|
360
|
+
if isinstance(node, ast.Subscript):
|
|
361
|
+
return self._emit_subscript(node)
|
|
362
|
+
|
|
363
|
+
if isinstance(node, ast.JoinedStr):
|
|
364
|
+
return self._emit_fstring(node)
|
|
365
|
+
|
|
366
|
+
if isinstance(node, ast.ListComp):
|
|
367
|
+
return self._emit_comprehension_chain(
|
|
368
|
+
node.generators, lambda: self.emit_expr(node.elt)
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
if isinstance(node, ast.GeneratorExp):
|
|
372
|
+
return self._emit_comprehension_chain(
|
|
373
|
+
node.generators, lambda: self.emit_expr(node.elt)
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
if isinstance(node, ast.SetComp):
|
|
377
|
+
arr = self._emit_comprehension_chain(
|
|
378
|
+
node.generators, lambda: self.emit_expr(node.elt)
|
|
379
|
+
)
|
|
380
|
+
return JSNew(JSIdentifier("Set"), [arr])
|
|
381
|
+
|
|
382
|
+
if isinstance(node, ast.DictComp):
|
|
383
|
+
pairs = self._emit_comprehension_chain(
|
|
384
|
+
node.generators,
|
|
385
|
+
lambda: JSArray([self.emit_expr(node.key), self.emit_expr(node.value)]),
|
|
386
|
+
)
|
|
387
|
+
return JSNew(JSIdentifier("Map"), [pairs])
|
|
388
|
+
|
|
389
|
+
if isinstance(node, ast.Lambda):
|
|
390
|
+
return self._emit_lambda(node)
|
|
391
|
+
|
|
392
|
+
if isinstance(node, ast.Starred):
|
|
393
|
+
return JSSpread(self.emit_expr(node.value))
|
|
394
|
+
|
|
395
|
+
if isinstance(node, ast.Await):
|
|
396
|
+
# Mark function as async when we encounter await
|
|
397
|
+
self._is_async = True
|
|
398
|
+
return JSAwait(self.emit_expr(node.value))
|
|
399
|
+
|
|
400
|
+
raise JSCompilationError(f"Unsupported expression: {type(node).__name__}")
|
|
401
|
+
|
|
402
|
+
def _emit_constant(self, node: ast.Constant) -> JSExpr:
|
|
403
|
+
"""Emit a constant value."""
|
|
404
|
+
v = node.value
|
|
405
|
+
if isinstance(v, str):
|
|
406
|
+
# Use template literals for strings with Unicode line separators
|
|
407
|
+
if "\u2028" in v or "\u2029" in v:
|
|
408
|
+
return JSTemplate([v])
|
|
409
|
+
return JSString(v)
|
|
410
|
+
if v is None:
|
|
411
|
+
return JSNull()
|
|
412
|
+
if v is True:
|
|
413
|
+
return JSBoolean(True)
|
|
414
|
+
if v is False:
|
|
415
|
+
return JSBoolean(False)
|
|
416
|
+
if isinstance(v, (int, float)):
|
|
417
|
+
return JSNumber(v)
|
|
418
|
+
raise JSCompilationError(f"Unsupported constant type: {type(v).__name__}")
|
|
419
|
+
|
|
420
|
+
def _emit_name(self, node: ast.Name) -> JSExpr:
|
|
421
|
+
"""Emit a name reference.
|
|
422
|
+
|
|
423
|
+
All dependencies are JSExpr subclasses. Behavior is encoded in hooks.
|
|
424
|
+
"""
|
|
425
|
+
name = node.id
|
|
426
|
+
|
|
427
|
+
# Check deps first - all are JSExpr
|
|
428
|
+
if name in self.deps:
|
|
429
|
+
return self.deps[name]
|
|
430
|
+
|
|
431
|
+
# Local variable
|
|
432
|
+
if name in self.locals:
|
|
433
|
+
return JSIdentifier(name)
|
|
434
|
+
|
|
435
|
+
raise JSCompilationError(f"Unbound name referenced: {name}")
|
|
436
|
+
|
|
437
|
+
def _emit_list_or_tuple(self, node: ast.List | ast.Tuple) -> JSExpr:
|
|
438
|
+
"""Emit a list or tuple literal."""
|
|
439
|
+
parts: list[JSExpr] = []
|
|
440
|
+
for e in node.elts:
|
|
441
|
+
if isinstance(e, ast.Starred):
|
|
442
|
+
parts.append(JSSpread(self.emit_expr(e.value)))
|
|
443
|
+
else:
|
|
444
|
+
parts.append(self.emit_expr(e))
|
|
445
|
+
return JSArray(parts)
|
|
446
|
+
|
|
447
|
+
def _emit_dict(self, node: ast.Dict) -> JSExpr:
|
|
448
|
+
"""Emit a dict literal as new Map([...])."""
|
|
449
|
+
entries: list[JSExpr] = []
|
|
450
|
+
for k, v in zip(node.keys, node.values, strict=False):
|
|
451
|
+
if k is None:
|
|
452
|
+
# Spread merge
|
|
453
|
+
vexpr = self.emit_expr(v)
|
|
454
|
+
is_map = JSBinary(vexpr, "instanceof", JSIdentifier("Map"))
|
|
455
|
+
map_entries = JSMemberCall(vexpr, "entries", [])
|
|
456
|
+
obj_entries = JSCall(
|
|
457
|
+
JSMember(JSIdentifier("Object"), "entries"), [vexpr]
|
|
458
|
+
)
|
|
459
|
+
entries.append(JSSpread(JSTertiary(is_map, map_entries, obj_entries)))
|
|
460
|
+
continue
|
|
461
|
+
key_expr = self.emit_expr(k)
|
|
462
|
+
val_expr = self.emit_expr(v)
|
|
463
|
+
entries.append(JSArray([key_expr, val_expr]))
|
|
464
|
+
return JSNew(JSIdentifier("Map"), [JSArray(entries)])
|
|
465
|
+
|
|
466
|
+
def _emit_binop(self, node: ast.BinOp) -> JSExpr:
|
|
467
|
+
"""Emit a binary operation."""
|
|
468
|
+
op = type(node.op)
|
|
469
|
+
if op not in ALLOWED_BINOPS:
|
|
470
|
+
raise JSCompilationError(f"Unsupported binary operator: {op.__name__}")
|
|
471
|
+
left = self.emit_expr(node.left)
|
|
472
|
+
right = self.emit_expr(node.right)
|
|
473
|
+
return JSBinary(left, ALLOWED_BINOPS[op], right)
|
|
474
|
+
|
|
475
|
+
def _emit_unaryop(self, node: ast.UnaryOp) -> JSExpr:
|
|
476
|
+
"""Emit a unary operation."""
|
|
477
|
+
op = type(node.op)
|
|
478
|
+
if op not in ALLOWED_UNOPS:
|
|
479
|
+
raise JSCompilationError(f"Unsupported unary operator: {op.__name__}")
|
|
480
|
+
return JSUnary(ALLOWED_UNOPS[op], self.emit_expr(node.operand))
|
|
481
|
+
|
|
482
|
+
def _emit_compare(self, node: ast.Compare) -> JSExpr:
|
|
483
|
+
"""Emit a comparison expression."""
|
|
484
|
+
operands: list[ast.expr] = [node.left, *node.comparators]
|
|
485
|
+
exprs: list[JSExpr] = [self.emit_expr(e) for e in operands]
|
|
486
|
+
cmp_parts: list[JSExpr] = []
|
|
487
|
+
|
|
488
|
+
for i, op in enumerate(node.ops):
|
|
489
|
+
left_node = operands[i]
|
|
490
|
+
right_node = operands[i + 1]
|
|
491
|
+
left_expr = exprs[i]
|
|
492
|
+
right_expr = exprs[i + 1]
|
|
493
|
+
cmp_parts.append(
|
|
494
|
+
self._build_comparison(left_expr, left_node, op, right_expr, right_node)
|
|
495
|
+
)
|
|
496
|
+
|
|
497
|
+
if len(cmp_parts) == 1:
|
|
498
|
+
return cmp_parts[0]
|
|
499
|
+
return JSLogicalChain("&&", cmp_parts)
|
|
500
|
+
|
|
501
|
+
def _build_comparison(
|
|
502
|
+
self,
|
|
503
|
+
left_expr: JSExpr,
|
|
504
|
+
left_node: ast.expr,
|
|
505
|
+
op: ast.cmpop,
|
|
506
|
+
right_expr: JSExpr,
|
|
507
|
+
right_node: ast.expr,
|
|
508
|
+
) -> JSExpr:
|
|
509
|
+
"""Build a single comparison."""
|
|
510
|
+
# Identity comparisons
|
|
511
|
+
if isinstance(op, (ast.Is, ast.IsNot)):
|
|
512
|
+
is_not = isinstance(op, ast.IsNot)
|
|
513
|
+
# Special case for None identity
|
|
514
|
+
if (isinstance(right_node, ast.Constant) and right_node.value is None) or (
|
|
515
|
+
isinstance(left_node, ast.Constant) and left_node.value is None
|
|
516
|
+
):
|
|
517
|
+
expr = right_expr if isinstance(left_node, ast.Constant) else left_expr
|
|
518
|
+
return JSBinary(expr, "!=" if is_not else "==", JSNull())
|
|
519
|
+
return JSBinary(left_expr, "!==" if is_not else "===", right_expr)
|
|
520
|
+
|
|
521
|
+
# Membership tests
|
|
522
|
+
if isinstance(op, (ast.In, ast.NotIn)):
|
|
523
|
+
return self._build_membership_test(
|
|
524
|
+
left_expr, right_expr, isinstance(op, ast.NotIn)
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
# Standard comparisons
|
|
528
|
+
op_type = type(op)
|
|
529
|
+
if op_type not in ALLOWED_CMPOPS:
|
|
530
|
+
raise JSCompilationError(
|
|
531
|
+
f"Unsupported comparison operator: {op_type.__name__}"
|
|
532
|
+
)
|
|
533
|
+
return JSBinary(left_expr, ALLOWED_CMPOPS[op_type], right_expr)
|
|
534
|
+
|
|
535
|
+
def _build_membership_test(
|
|
536
|
+
self, item: JSExpr, container: JSExpr, negate: bool
|
|
537
|
+
) -> JSExpr:
|
|
538
|
+
"""Build a membership test (in / not in)."""
|
|
539
|
+
is_string = JSBinary(JSUnary("typeof", container), "===", JSString("string"))
|
|
540
|
+
is_array = JSMemberCall(JSIdentifier("Array"), "isArray", [container])
|
|
541
|
+
is_set = JSBinary(container, "instanceof", JSIdentifier("Set"))
|
|
542
|
+
is_map = JSBinary(container, "instanceof", JSIdentifier("Map"))
|
|
543
|
+
|
|
544
|
+
is_array_or_string = JSLogicalChain("||", [is_array, is_string])
|
|
545
|
+
is_set_or_map = JSLogicalChain("||", [is_set, is_map])
|
|
546
|
+
|
|
547
|
+
has_array_or_string = JSMemberCall(container, "includes", [item])
|
|
548
|
+
has_set_or_map = JSMemberCall(container, "has", [item])
|
|
549
|
+
has_obj = JSBinary(item, "in", container)
|
|
550
|
+
|
|
551
|
+
membership_expr = JSTertiary(
|
|
552
|
+
is_array_or_string,
|
|
553
|
+
has_array_or_string,
|
|
554
|
+
JSTertiary(is_set_or_map, has_set_or_map, has_obj),
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
if negate:
|
|
558
|
+
return JSUnary("!", membership_expr)
|
|
559
|
+
return membership_expr
|
|
560
|
+
|
|
561
|
+
def _emit_call(self, node: ast.Call) -> JSExpr:
|
|
562
|
+
"""Emit a function call.
|
|
563
|
+
|
|
564
|
+
All behavior is encoded in JSExpr.emit_call hooks.
|
|
565
|
+
emit_call receives raw Python values (JSExpr instances from emit_expr),
|
|
566
|
+
and decides what to convert using JSExpr.of().
|
|
567
|
+
"""
|
|
568
|
+
# Handle typing.cast: ignore type argument, return value unchanged
|
|
569
|
+
# Must short-circuit before evaluating args to avoid transpiling type annotations
|
|
570
|
+
if isinstance(node.func, ast.Name) and node.func.id == "cast":
|
|
571
|
+
if len(node.args) >= 2:
|
|
572
|
+
return self.emit_expr(node.args[1])
|
|
573
|
+
raise JSCompilationError("typing.cast requires two arguments")
|
|
574
|
+
|
|
575
|
+
# Emit args as JSExpr (they're already the transpiled form)
|
|
576
|
+
args: list[Any] = [self.emit_expr(a) for a in node.args]
|
|
577
|
+
kwargs = self._build_kwargs(node)
|
|
578
|
+
|
|
579
|
+
# Method call: obj.method(args) -> obj.emit_getattr(method).emit_call(args)
|
|
580
|
+
if isinstance(node.func, ast.Attribute):
|
|
581
|
+
obj = self.emit_expr(node.func.value)
|
|
582
|
+
method_expr = obj.emit_getattr(node.func.attr)
|
|
583
|
+
return method_expr.emit_call(args, kwargs)
|
|
584
|
+
|
|
585
|
+
# Function call
|
|
586
|
+
callee = self.emit_expr(node.func)
|
|
587
|
+
return callee.emit_call(args, kwargs)
|
|
588
|
+
|
|
589
|
+
def _build_kwargs(self, node: ast.Call) -> dict[str, Any]:
|
|
590
|
+
"""Build kwargs dict from AST Call node.
|
|
591
|
+
|
|
592
|
+
Returns a dict mapping:
|
|
593
|
+
- "propName" -> JSExpr for named kwargs (as raw values)
|
|
594
|
+
- "$spread{N}" -> JSSpread(expr) for **spread kwargs
|
|
595
|
+
|
|
596
|
+
Dict order is preserved (Python 3.7+), so iteration order matches source order.
|
|
597
|
+
Uses $ prefix for spreads since it's not a valid Python identifier.
|
|
598
|
+
"""
|
|
599
|
+
kwargs: dict[str, Any] = {}
|
|
600
|
+
spread_count = 0
|
|
601
|
+
|
|
602
|
+
for kw in node.keywords:
|
|
603
|
+
if kw.arg is None:
|
|
604
|
+
# **kwargs spread - use invalid Python identifier to avoid conflicts
|
|
605
|
+
kwargs[f"$spread{spread_count}"] = JSSpread(self.emit_expr(kw.value))
|
|
606
|
+
spread_count += 1
|
|
607
|
+
else:
|
|
608
|
+
kwargs[kw.arg] = self.emit_expr(kw.value)
|
|
609
|
+
return kwargs
|
|
610
|
+
|
|
611
|
+
def _emit_attribute(self, node: ast.Attribute) -> JSExpr:
|
|
612
|
+
"""Emit an attribute access.
|
|
613
|
+
|
|
614
|
+
All behavior is encoded in JSExpr.emit_getattr hooks.
|
|
615
|
+
"""
|
|
616
|
+
value = self.emit_expr(node.value)
|
|
617
|
+
return value.emit_getattr(node.attr)
|
|
618
|
+
|
|
619
|
+
def _emit_subscript(self, node: ast.Subscript) -> JSExpr:
|
|
620
|
+
"""Emit a subscript expression."""
|
|
621
|
+
value = self.emit_expr(node.value)
|
|
622
|
+
|
|
623
|
+
# Slice handling (not passed through emit_subscript hook)
|
|
624
|
+
if isinstance(node.slice, ast.Slice):
|
|
625
|
+
return self._emit_slice(value, node.slice)
|
|
626
|
+
|
|
627
|
+
# Negative index: use .at() (not passed through emit_subscript hook)
|
|
628
|
+
if isinstance(node.slice, ast.UnaryOp) and isinstance(node.slice.op, ast.USub):
|
|
629
|
+
idx_expr = self.emit_expr(node.slice.operand)
|
|
630
|
+
return JSMemberCall(value, "at", [JSUnary("-", idx_expr)])
|
|
631
|
+
|
|
632
|
+
# Collect indices - tuple means multiple indices like x[a, b, c]
|
|
633
|
+
# Pass as raw values (JSExpr instances) to emit_subscript
|
|
634
|
+
if isinstance(node.slice, ast.Tuple):
|
|
635
|
+
indices: list[Any] = [self.emit_expr(e) for e in node.slice.elts]
|
|
636
|
+
else:
|
|
637
|
+
indices = [self.emit_expr(node.slice)]
|
|
638
|
+
|
|
639
|
+
# Use emit_subscript hook for extensibility
|
|
640
|
+
return value.emit_subscript(indices)
|
|
641
|
+
|
|
642
|
+
def _emit_slice(self, value: JSExpr, slice_node: ast.Slice) -> JSExpr:
|
|
643
|
+
"""Emit a slice operation."""
|
|
644
|
+
if slice_node.step is not None:
|
|
645
|
+
raise JSCompilationError("Slice steps are not supported")
|
|
646
|
+
|
|
647
|
+
lower = slice_node.lower
|
|
648
|
+
upper = slice_node.upper
|
|
649
|
+
|
|
650
|
+
if lower is None and upper is None:
|
|
651
|
+
return JSMemberCall(value, "slice", [])
|
|
652
|
+
elif lower is None:
|
|
653
|
+
return JSMemberCall(value, "slice", [JSNumber(0), self.emit_expr(upper)])
|
|
654
|
+
elif upper is None:
|
|
655
|
+
return JSMemberCall(value, "slice", [self.emit_expr(lower)])
|
|
656
|
+
else:
|
|
657
|
+
return JSMemberCall(
|
|
658
|
+
value, "slice", [self.emit_expr(lower), self.emit_expr(upper)]
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
def _emit_fstring(self, node: ast.JoinedStr) -> JSExpr:
|
|
662
|
+
"""Emit an f-string as a template literal."""
|
|
663
|
+
parts: list[str | JSExpr] = []
|
|
664
|
+
for part in node.values:
|
|
665
|
+
if isinstance(part, ast.Constant) and isinstance(part.value, str):
|
|
666
|
+
parts.append(part.value)
|
|
667
|
+
elif isinstance(part, ast.FormattedValue):
|
|
668
|
+
expr = self.emit_expr(part.value)
|
|
669
|
+
# Handle conversion flags: !s, !r, !a
|
|
670
|
+
if part.conversion == ord("s"):
|
|
671
|
+
expr = JSCall(JSIdentifier("String"), [expr])
|
|
672
|
+
elif part.conversion == ord("r"):
|
|
673
|
+
expr = JSCall(JSMember(JSIdentifier("JSON"), "stringify"), [expr])
|
|
674
|
+
elif part.conversion == ord("a"):
|
|
675
|
+
# !a is ASCII repr - approximate with JSON.stringify
|
|
676
|
+
expr = JSCall(JSMember(JSIdentifier("JSON"), "stringify"), [expr])
|
|
677
|
+
# Handle format_spec (it's always a JoinedStr in practice)
|
|
678
|
+
if part.format_spec is not None:
|
|
679
|
+
if not isinstance(part.format_spec, ast.JoinedStr):
|
|
680
|
+
raise JSCompilationError("Format spec must be a JoinedStr")
|
|
681
|
+
expr = self._apply_format_spec(expr, part.format_spec)
|
|
682
|
+
parts.append(expr)
|
|
683
|
+
else:
|
|
684
|
+
raise JSCompilationError(
|
|
685
|
+
f"Unsupported f-string component: {type(part).__name__}"
|
|
686
|
+
)
|
|
687
|
+
return JSTemplate(parts)
|
|
688
|
+
|
|
689
|
+
def _apply_format_spec(self, expr: JSExpr, format_spec: ast.JoinedStr) -> JSExpr:
|
|
690
|
+
"""Apply a Python format spec to an expression.
|
|
691
|
+
|
|
692
|
+
Supports common format specs:
|
|
693
|
+
- .Nf: N decimal places (float) -> .toFixed(N)
|
|
694
|
+
- 0Nd: zero-padded integer, width N -> String(...).padStart(N, '0')
|
|
695
|
+
- >N: right-align, width N -> String(...).padStart(N)
|
|
696
|
+
- <N: left-align, width N -> String(...).padEnd(N)
|
|
697
|
+
- ^N: center, width N -> custom centering
|
|
698
|
+
- #x, #o, #b: hex/octal/binary with prefix
|
|
699
|
+
- +.Nf: with sign prefix
|
|
700
|
+
"""
|
|
701
|
+
# Extract the format spec string (it's a JoinedStr but usually just one constant)
|
|
702
|
+
if len(format_spec.values) != 1:
|
|
703
|
+
raise JSCompilationError("Dynamic format specs not supported")
|
|
704
|
+
spec_part = format_spec.values[0]
|
|
705
|
+
if not isinstance(spec_part, ast.Constant) or not isinstance(
|
|
706
|
+
spec_part.value, str
|
|
707
|
+
):
|
|
708
|
+
raise JSCompilationError("Dynamic format specs not supported")
|
|
709
|
+
|
|
710
|
+
spec = spec_part.value
|
|
711
|
+
return self._parse_and_apply_format(expr, spec)
|
|
712
|
+
|
|
713
|
+
def _parse_and_apply_format(self, expr: JSExpr, spec: str) -> JSExpr:
|
|
714
|
+
"""Parse a format spec string and apply it to expr."""
|
|
715
|
+
if not spec:
|
|
716
|
+
return expr
|
|
717
|
+
|
|
718
|
+
# Parse Python format spec: [[fill]align][sign][#][0][width][,][.precision][type]
|
|
719
|
+
pattern = r"^([^<>=^]?[<>=^])?([+\- ])?([#])?(0)?(\d+)?([,_])?(\.(\d+))?([bcdeEfFgGnosxX%])?$"
|
|
720
|
+
match = re.match(pattern, spec)
|
|
721
|
+
if not match:
|
|
722
|
+
raise JSCompilationError(f"Unsupported format spec: {spec!r}")
|
|
723
|
+
|
|
724
|
+
align_part = match.group(1) or ""
|
|
725
|
+
sign = match.group(2) or ""
|
|
726
|
+
alt_form = match.group(3) # '#'
|
|
727
|
+
zero_pad = match.group(4) # '0'
|
|
728
|
+
width_str = match.group(5)
|
|
729
|
+
# thousands_sep = match.group(6) # ',' or '_' - not commonly needed
|
|
730
|
+
precision_str = match.group(8)
|
|
731
|
+
type_char = match.group(9) or ""
|
|
732
|
+
|
|
733
|
+
width = int(width_str) if width_str else None
|
|
734
|
+
precision = int(precision_str) if precision_str else None
|
|
735
|
+
|
|
736
|
+
# Determine fill and alignment
|
|
737
|
+
if len(align_part) == 2:
|
|
738
|
+
fill = align_part[0]
|
|
739
|
+
align = align_part[1]
|
|
740
|
+
elif len(align_part) == 1:
|
|
741
|
+
fill = " "
|
|
742
|
+
align = align_part[0]
|
|
743
|
+
else:
|
|
744
|
+
fill = " "
|
|
745
|
+
align = ""
|
|
746
|
+
|
|
747
|
+
# Handle type conversions first
|
|
748
|
+
if type_char in ("f", "F"):
|
|
749
|
+
# Float with precision
|
|
750
|
+
prec = precision if precision is not None else 6
|
|
751
|
+
expr = JSMemberCall(expr, "toFixed", [JSNumber(prec)])
|
|
752
|
+
if sign == "+":
|
|
753
|
+
# Add sign prefix for positive numbers
|
|
754
|
+
expr = JSTertiary(
|
|
755
|
+
JSBinary(expr, ">=", JSNumber(0)),
|
|
756
|
+
JSBinary(JSString("+"), "+", expr),
|
|
757
|
+
expr,
|
|
758
|
+
)
|
|
759
|
+
elif type_char == "d":
|
|
760
|
+
# Integer - convert to string for padding (only if we need padding later)
|
|
761
|
+
if width is not None:
|
|
762
|
+
expr = JSCall(JSIdentifier("String"), [expr])
|
|
763
|
+
elif type_char == "x":
|
|
764
|
+
# Hex lowercase
|
|
765
|
+
base_expr = JSMemberCall(expr, "toString", [JSNumber(16)])
|
|
766
|
+
if alt_form:
|
|
767
|
+
expr = JSBinary(JSString("0x"), "+", base_expr)
|
|
768
|
+
else:
|
|
769
|
+
expr = base_expr
|
|
770
|
+
elif type_char == "X":
|
|
771
|
+
# Hex uppercase
|
|
772
|
+
base_expr = JSMemberCall(
|
|
773
|
+
JSMemberCall(expr, "toString", [JSNumber(16)]), "toUpperCase", []
|
|
774
|
+
)
|
|
775
|
+
if alt_form:
|
|
776
|
+
expr = JSBinary(JSString("0x"), "+", base_expr)
|
|
777
|
+
else:
|
|
778
|
+
expr = base_expr
|
|
779
|
+
elif type_char == "o":
|
|
780
|
+
# Octal
|
|
781
|
+
base_expr = JSMemberCall(expr, "toString", [JSNumber(8)])
|
|
782
|
+
if alt_form:
|
|
783
|
+
expr = JSBinary(JSString("0o"), "+", base_expr)
|
|
784
|
+
else:
|
|
785
|
+
expr = base_expr
|
|
786
|
+
elif type_char == "b":
|
|
787
|
+
# Binary
|
|
788
|
+
base_expr = JSMemberCall(expr, "toString", [JSNumber(2)])
|
|
789
|
+
if alt_form:
|
|
790
|
+
expr = JSBinary(JSString("0b"), "+", base_expr)
|
|
791
|
+
else:
|
|
792
|
+
expr = base_expr
|
|
793
|
+
elif type_char == "e":
|
|
794
|
+
# Exponential notation lowercase
|
|
795
|
+
prec = precision if precision is not None else 6
|
|
796
|
+
expr = JSMemberCall(expr, "toExponential", [JSNumber(prec)])
|
|
797
|
+
elif type_char == "E":
|
|
798
|
+
# Exponential notation uppercase
|
|
799
|
+
prec = precision if precision is not None else 6
|
|
800
|
+
expr = JSMemberCall(
|
|
801
|
+
JSMemberCall(expr, "toExponential", [JSNumber(prec)]), "toUpperCase", []
|
|
802
|
+
)
|
|
803
|
+
elif type_char == "s" or type_char == "":
|
|
804
|
+
# String - convert to string if not already
|
|
805
|
+
if type_char == "s" or (width is not None and align):
|
|
806
|
+
expr = JSCall(JSIdentifier("String"), [expr])
|
|
807
|
+
|
|
808
|
+
# Apply width/padding
|
|
809
|
+
if width is not None:
|
|
810
|
+
fill_str = JSString(fill)
|
|
811
|
+
width_num = JSNumber(width)
|
|
812
|
+
|
|
813
|
+
if zero_pad and not align:
|
|
814
|
+
# Zero padding (e.g., 05d) - pad start with zeros
|
|
815
|
+
# If expr is not already a string, wrap it
|
|
816
|
+
if not self._is_string_expr(expr):
|
|
817
|
+
expr = JSCall(JSIdentifier("String"), [expr])
|
|
818
|
+
expr = JSMemberCall(
|
|
819
|
+
expr,
|
|
820
|
+
"padStart",
|
|
821
|
+
[width_num, JSString("0")],
|
|
822
|
+
)
|
|
823
|
+
elif align == "<":
|
|
824
|
+
# Left align -> padEnd
|
|
825
|
+
expr = JSMemberCall(expr, "padEnd", [width_num, fill_str])
|
|
826
|
+
elif align == ">":
|
|
827
|
+
# Right align -> padStart
|
|
828
|
+
expr = JSMemberCall(expr, "padStart", [width_num, fill_str])
|
|
829
|
+
elif align == "^":
|
|
830
|
+
# Center align - needs custom logic
|
|
831
|
+
# JS: s.padStart((width + s.length) / 2).padEnd(width)
|
|
832
|
+
expr = JSMemberCall(
|
|
833
|
+
JSMemberCall(
|
|
834
|
+
expr,
|
|
835
|
+
"padStart",
|
|
836
|
+
[
|
|
837
|
+
JSBinary(
|
|
838
|
+
JSBinary(
|
|
839
|
+
JSBinary(width_num, "+", JSMember(expr, "length")),
|
|
840
|
+
"/",
|
|
841
|
+
JSNumber(2),
|
|
842
|
+
),
|
|
843
|
+
"|",
|
|
844
|
+
JSNumber(0),
|
|
845
|
+
),
|
|
846
|
+
fill_str,
|
|
847
|
+
],
|
|
848
|
+
),
|
|
849
|
+
"padEnd",
|
|
850
|
+
[width_num, fill_str],
|
|
851
|
+
)
|
|
852
|
+
elif align == "=":
|
|
853
|
+
# Pad after sign - not commonly used, treat as right align
|
|
854
|
+
expr = JSMemberCall(expr, "padStart", [width_num, fill_str])
|
|
855
|
+
elif zero_pad:
|
|
856
|
+
# Just 0N without explicit align means zero-pad from start
|
|
857
|
+
expr = JSMemberCall(
|
|
858
|
+
JSCall(JSIdentifier("String"), [expr]),
|
|
859
|
+
"padStart",
|
|
860
|
+
[width_num, JSString("0")],
|
|
861
|
+
)
|
|
862
|
+
|
|
863
|
+
return expr
|
|
864
|
+
|
|
865
|
+
def _emit_lambda(self, node: ast.Lambda) -> JSExpr:
|
|
866
|
+
"""Emit a lambda expression as an arrow function."""
|
|
867
|
+
# Get parameter names
|
|
868
|
+
params = [arg.arg for arg in node.args.args]
|
|
869
|
+
# Add params to locals temporarily
|
|
870
|
+
saved_locals = set(self.locals)
|
|
871
|
+
self.locals.update(params)
|
|
872
|
+
|
|
873
|
+
body = self.emit_expr(node.body)
|
|
874
|
+
|
|
875
|
+
self.locals = saved_locals
|
|
876
|
+
|
|
877
|
+
if len(params) == 0:
|
|
878
|
+
return JSArrowFunction("()", body)
|
|
879
|
+
elif len(params) == 1:
|
|
880
|
+
return JSArrowFunction(params[0], body)
|
|
881
|
+
else:
|
|
882
|
+
return JSArrowFunction(f"({', '.join(params)})", body)
|
|
883
|
+
|
|
884
|
+
def _emit_comprehension_chain(
|
|
885
|
+
self,
|
|
886
|
+
generators: list[ast.comprehension],
|
|
887
|
+
build_last: Callable[[], JSExpr],
|
|
888
|
+
) -> JSExpr:
|
|
889
|
+
"""Build a flatMap/map chain for comprehensions."""
|
|
890
|
+
if len(generators) == 0:
|
|
891
|
+
raise JSCompilationError("Empty comprehension")
|
|
892
|
+
|
|
893
|
+
saved_locals = set(self.locals)
|
|
894
|
+
|
|
895
|
+
def build_chain(gen_index: int) -> JSExpr:
|
|
896
|
+
gen = generators[gen_index]
|
|
897
|
+
if gen.is_async:
|
|
898
|
+
raise JSCompilationError("Async comprehensions are not supported")
|
|
899
|
+
|
|
900
|
+
iter_expr = self.emit_expr(gen.iter)
|
|
901
|
+
# Get arrow function parameter code and variable names from a target
|
|
902
|
+
if isinstance(gen.target, ast.Name):
|
|
903
|
+
param_code = gen.target.id
|
|
904
|
+
names = [gen.target.id]
|
|
905
|
+
elif isinstance(gen.target, ast.Tuple) and all(
|
|
906
|
+
isinstance(e, ast.Name) for e in gen.target.elts
|
|
907
|
+
):
|
|
908
|
+
names = [e.id for e in gen.target.elts if isinstance(e, ast.Name)]
|
|
909
|
+
param_code = f"([{', '.join(names)}])"
|
|
910
|
+
else:
|
|
911
|
+
raise JSCompilationError(
|
|
912
|
+
"Only name or tuple targets supported in comprehensions"
|
|
913
|
+
)
|
|
914
|
+
for nm in names:
|
|
915
|
+
self.locals.add(nm)
|
|
916
|
+
|
|
917
|
+
base = iter_expr
|
|
918
|
+
|
|
919
|
+
# Apply filters
|
|
920
|
+
if gen.ifs:
|
|
921
|
+
conds = [self.emit_expr(test) for test in gen.ifs]
|
|
922
|
+
cond = JSLogicalChain("&&", conds) if len(conds) > 1 else conds[0]
|
|
923
|
+
base = JSMemberCall(base, "filter", [JSArrowFunction(param_code, cond)])
|
|
924
|
+
|
|
925
|
+
is_last = gen_index == len(generators) - 1
|
|
926
|
+
if is_last:
|
|
927
|
+
elt_expr = build_last()
|
|
928
|
+
return JSMemberCall(
|
|
929
|
+
base, "map", [JSArrowFunction(param_code, elt_expr)]
|
|
930
|
+
)
|
|
931
|
+
|
|
932
|
+
inner = build_chain(gen_index + 1)
|
|
933
|
+
return JSMemberCall(base, "flatMap", [JSArrowFunction(param_code, inner)])
|
|
934
|
+
|
|
935
|
+
try:
|
|
936
|
+
return build_chain(0)
|
|
937
|
+
finally:
|
|
938
|
+
self.locals = saved_locals
|