pulse-framework 0.1.51__py3-none-any.whl → 0.1.52__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.
Files changed (84) hide show
  1. pulse/__init__.py +542 -562
  2. pulse/_examples.py +29 -0
  3. pulse/app.py +0 -14
  4. pulse/cli/cmd.py +96 -80
  5. pulse/cli/dependencies.py +10 -41
  6. pulse/cli/folder_lock.py +3 -3
  7. pulse/cli/helpers.py +40 -67
  8. pulse/cli/logging.py +102 -0
  9. pulse/cli/packages.py +16 -0
  10. pulse/cli/processes.py +40 -23
  11. pulse/codegen/codegen.py +70 -35
  12. pulse/codegen/js.py +2 -4
  13. pulse/codegen/templates/route.py +94 -146
  14. pulse/component.py +115 -0
  15. pulse/components/for_.py +1 -1
  16. pulse/components/if_.py +1 -1
  17. pulse/components/react_router.py +16 -22
  18. pulse/{html → dom}/events.py +1 -1
  19. pulse/{html → dom}/props.py +6 -6
  20. pulse/{html → dom}/tags.py +11 -11
  21. pulse/dom/tags.pyi +480 -0
  22. pulse/form.py +7 -6
  23. pulse/hooks/init.py +1 -13
  24. pulse/js/__init__.py +37 -41
  25. pulse/js/__init__.pyi +22 -2
  26. pulse/js/_types.py +5 -3
  27. pulse/js/array.py +121 -38
  28. pulse/js/console.py +9 -9
  29. pulse/js/date.py +22 -19
  30. pulse/js/document.py +8 -4
  31. pulse/js/error.py +12 -14
  32. pulse/js/json.py +4 -3
  33. pulse/js/map.py +17 -7
  34. pulse/js/math.py +2 -2
  35. pulse/js/navigator.py +4 -4
  36. pulse/js/number.py +8 -8
  37. pulse/js/object.py +9 -13
  38. pulse/js/promise.py +25 -9
  39. pulse/js/regexp.py +6 -6
  40. pulse/js/set.py +20 -8
  41. pulse/js/string.py +7 -7
  42. pulse/js/weakmap.py +6 -6
  43. pulse/js/weakset.py +6 -6
  44. pulse/js/window.py +17 -14
  45. pulse/messages.py +1 -4
  46. pulse/react_component.py +3 -1001
  47. pulse/render_session.py +74 -66
  48. pulse/renderer.py +311 -238
  49. pulse/routing.py +1 -10
  50. pulse/transpiler/__init__.py +84 -114
  51. pulse/transpiler/builtins.py +661 -343
  52. pulse/transpiler/errors.py +78 -2
  53. pulse/transpiler/function.py +463 -133
  54. pulse/transpiler/id.py +18 -0
  55. pulse/transpiler/imports.py +230 -325
  56. pulse/transpiler/js_module.py +218 -209
  57. pulse/transpiler/modules/__init__.py +16 -13
  58. pulse/transpiler/modules/asyncio.py +45 -26
  59. pulse/transpiler/modules/json.py +12 -8
  60. pulse/transpiler/modules/math.py +161 -216
  61. pulse/transpiler/modules/pulse/__init__.py +5 -0
  62. pulse/transpiler/modules/pulse/tags.py +231 -0
  63. pulse/transpiler/modules/typing.py +33 -28
  64. pulse/transpiler/nodes.py +1607 -923
  65. pulse/transpiler/py_module.py +118 -95
  66. pulse/transpiler/react_component.py +51 -0
  67. pulse/transpiler/transpiler.py +593 -437
  68. pulse/transpiler/vdom.py +255 -0
  69. {pulse_framework-0.1.51.dist-info → pulse_framework-0.1.52.dist-info}/METADATA +1 -1
  70. pulse_framework-0.1.52.dist-info/RECORD +120 -0
  71. pulse/html/tags.pyi +0 -470
  72. pulse/transpiler/constants.py +0 -110
  73. pulse/transpiler/context.py +0 -26
  74. pulse/transpiler/ids.py +0 -16
  75. pulse/transpiler/modules/re.py +0 -466
  76. pulse/transpiler/modules/tags.py +0 -268
  77. pulse/transpiler/utils.py +0 -4
  78. pulse/vdom.py +0 -599
  79. pulse_framework-0.1.51.dist-info/RECORD +0 -119
  80. /pulse/{html → dom}/__init__.py +0 -0
  81. /pulse/{html → dom}/elements.py +0 -0
  82. /pulse/{html → dom}/svg.py +0 -0
  83. {pulse_framework-0.1.51.dist-info → pulse_framework-0.1.52.dist-info}/WHEEL +0 -0
  84. {pulse_framework-0.1.51.dist-info → pulse_framework-0.1.52.dist-info}/entry_points.txt +0 -0
@@ -1,93 +1,129 @@
1
1
  """
2
- Python -> JavaScript transpiler for pure functions.
2
+ Python -> JavaScript transpiler using v2 nodes.
3
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.
4
+ Transpiles a restricted subset of Python into v2 Expr/Stmt AST nodes.
5
+ Dependencies are resolved through a dict[str, Expr] mapping.
11
6
  """
12
7
 
13
8
  from __future__ import annotations
14
9
 
15
10
  import ast
16
11
  import re
17
- from collections.abc import Callable
12
+ from collections.abc import Callable, Mapping
18
13
  from typing import Any
19
14
 
20
- from pulse.transpiler.errors import JSCompilationError
15
+ from pulse.transpiler.builtins import BUILTINS, emit_method
16
+ from pulse.transpiler.errors import TranspileError
21
17
  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,
18
+ Array,
19
+ Arrow,
20
+ Assign,
21
+ Binary,
22
+ Block,
23
+ Break,
24
+ Call,
25
+ Continue,
26
+ Expr,
27
+ ExprStmt,
28
+ ForOf,
29
+ Function,
30
+ Identifier,
31
+ If,
32
+ Literal,
33
+ Member,
34
+ Return,
35
+ Spread,
36
+ Stmt,
37
+ StmtSequence,
38
+ Subscript,
39
+ Template,
40
+ Ternary,
41
+ Throw,
42
+ TryStmt,
43
+ Unary,
44
+ While,
60
45
  )
61
46
 
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).
47
+ ALLOWED_BINOPS: dict[type[ast.operator], str] = {
48
+ ast.Add: "+",
49
+ ast.Sub: "-",
50
+ ast.Mult: "*",
51
+ ast.Div: "/",
52
+ ast.Mod: "%",
53
+ ast.Pow: "**",
54
+ # Bitwise operators
55
+ ast.BitAnd: "&",
56
+ ast.BitOr: "|",
57
+ ast.BitXor: "^",
58
+ ast.LShift: "<<",
59
+ ast.RShift: ">>",
60
+ }
61
+
62
+ ALLOWED_UNOPS: dict[type[ast.unaryop], str] = {
63
+ ast.UAdd: "+",
64
+ ast.USub: "-",
65
+ ast.Not: "!",
66
+ ast.Invert: "~", # Bitwise NOT
67
+ }
68
+
69
+ ALLOWED_CMPOPS: dict[type[ast.cmpop], str] = {
70
+ ast.Eq: "===",
71
+ ast.NotEq: "!==",
72
+ ast.Lt: "<",
73
+ ast.LtE: "<=",
74
+ ast.Gt: ">",
75
+ ast.GtE: ">=",
76
+ }
77
+
78
+
79
+ class Transpiler:
80
+ """Transpile Python AST to v2 Expr/Stmt AST nodes.
81
+
82
+ Takes a function definition and a dictionary of dependencies.
83
+ Dependencies are substituted when their names are referenced.
84
+
85
+ Dependencies are Expr instances. Expr subclasses can override:
86
+ - transpile_call: custom call behavior (e.g., JSX components)
87
+ - transpile_getattr: custom attribute access
88
+ - transpile_subscript: custom subscript behavior
68
89
  """
69
90
 
70
91
  fndef: ast.FunctionDef | ast.AsyncFunctionDef
71
92
  args: list[str]
72
- deps: dict[str, JSExpr]
93
+ deps: Mapping[str, Expr]
73
94
  locals: set[str]
95
+ jsx: bool
74
96
  _temp_counter: int
75
- _is_async: bool
76
97
 
77
98
  def __init__(
78
99
  self,
79
100
  fndef: ast.FunctionDef | ast.AsyncFunctionDef,
80
- args: list[str],
81
- deps: dict[str, JSExpr],
101
+ deps: Mapping[str, Expr],
102
+ *,
103
+ jsx: bool = False,
82
104
  ) -> None:
83
105
  self.fndef = fndef
106
+ # Collect all argument names (regular, vararg, kwonly, kwarg)
107
+ args: list[str] = [arg.arg for arg in fndef.args.args]
108
+ if fndef.args.vararg:
109
+ args.append(fndef.args.vararg.arg)
110
+ args.extend(arg.arg for arg in fndef.args.kwonlyargs)
111
+ if fndef.args.kwarg:
112
+ args.append(fndef.args.kwarg.arg)
84
113
  self.args = args
85
114
  self.deps = deps
86
- # Track locals for declaration decisions (args are predeclared)
87
- self.locals = set(args)
115
+ self.jsx = jsx
116
+ self.locals = set(self.args)
88
117
  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)
118
+ self.init_temp_counter()
119
+
120
+ def init_temp_counter(self) -> None:
121
+ """Initialize temp counter to avoid collisions with args or globals."""
122
+ all_names = set(self.args) | set(self.deps.keys())
123
+ counter = 0
124
+ while f"$tmp{counter}" in all_names:
125
+ counter += 1
126
+ self._temp_counter = counter
91
127
 
92
128
  def _fresh_temp(self) -> str:
93
129
  """Generate a fresh temporary variable name."""
@@ -95,82 +131,141 @@ class JsTranspiler(ast.NodeVisitor):
95
131
  self._temp_counter += 1
96
132
  return name
97
133
 
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
134
  # --- Entrypoint ---------------------------------------------------------
120
- def transpile(self, name: str | None = None) -> JSFunctionDef:
121
- """Transpile the function definition to a JS function.
122
135
 
123
- Args:
124
- name: Optional function name to emit. If None, emits anonymous function.
136
+ def transpile(self) -> Function | Arrow:
137
+ """Transpile the function to a Function or Arrow node.
138
+
139
+ For single-expression functions (or single return), produces Arrow:
140
+ (params) => expr
141
+
142
+ For multi-statement functions, produces Function:
143
+ function(params) { ... }
144
+
145
+ For JSX functions, produces Function with destructured props parameter:
146
+ function({param1, param2 = default}) { ... }
125
147
  """
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)
148
+ body = self.fndef.body
149
+
150
+ # Skip docstrings
151
+ if (
152
+ body
153
+ and isinstance(body[0], ast.Expr)
154
+ and isinstance(body[0].value, ast.Constant)
155
+ and isinstance(body[0].value.value, str)
156
+ ):
157
+ body = body[1:]
158
+
159
+ # Arrow optimizations (only for non-JSX)
160
+ if not self.jsx:
161
+ if not body:
162
+ return Arrow(self.args, Literal(None))
163
+
164
+ if len(body) == 1:
165
+ stmt = body[0]
166
+ if isinstance(stmt, ast.Return):
167
+ expr = self.emit_expr(stmt.value)
168
+ return Arrow(self.args, expr)
169
+ if isinstance(stmt, ast.Expr):
170
+ expr = self.emit_expr(stmt.value)
171
+ return Arrow(self.args, expr)
172
+
173
+ # General case: Function (for JSX or multi-statement)
174
+ stmts = [self.emit_stmt(s) for s in body]
175
+ is_async = isinstance(self.fndef, ast.AsyncFunctionDef)
176
+ args = [self._jsx_args()] if self.jsx else self.args
177
+ return Function(args, stmts, is_async=is_async)
178
+
179
+ def _jsx_args(self) -> str:
180
+ """Build a destructured props parameter for JSX functions.
181
+
182
+ React components receive a single props object, so parameters
183
+ are emitted as a destructuring pattern: {param1, param2 = default, ...}
184
+ """
185
+ args = self.fndef.args
186
+ destructure_parts: list[str] = []
187
+ default_out: list[str] = []
188
+
189
+ # Regular arguments (may have defaults at the end)
190
+ num_defaults = len(args.defaults)
191
+ num_args = len(args.args)
192
+ for i, arg in enumerate(args.args):
193
+ param_name = arg.arg
194
+ # Defaults align to the right: if we have 3 args and 1 default,
195
+ # the default is for args[2], not args[0]
196
+ default_idx = i - (num_args - num_defaults)
197
+ if default_idx >= 0:
198
+ # Has a default value
199
+ default_node = args.defaults[default_idx]
200
+ default_expr = self.emit_expr(default_node)
201
+ default_out.clear()
202
+ default_expr.emit(default_out)
203
+ destructure_parts.append(f"{param_name} = {''.join(default_out)}")
204
+ else:
205
+ # No default
206
+ destructure_parts.append(param_name)
207
+
208
+ # *args (VAR_POSITIONAL)
209
+ if args.vararg:
210
+ destructure_parts.append(args.vararg.arg)
211
+
212
+ # Keyword-only arguments
213
+ for i, arg in enumerate(args.kwonlyargs):
214
+ param_name = arg.arg
215
+ default_node = args.kw_defaults[i]
216
+ if default_node is not None:
217
+ # Has a default value
218
+ default_expr = self.emit_expr(default_node)
219
+ default_out.clear()
220
+ default_expr.emit(default_out)
221
+ destructure_parts.append(f"{param_name} = {''.join(default_out)}")
222
+ else:
223
+ # No default
224
+ destructure_parts.append(param_name)
225
+
226
+ # **kwargs (VAR_KEYWORD)
227
+ if args.kwarg:
228
+ destructure_parts.append(f"...{args.kwarg.arg}")
229
+
230
+ return "{" + ", ".join(destructure_parts) + "}"
141
231
 
142
232
  # --- Statements ----------------------------------------------------------
143
- def emit_stmt(self, node: ast.stmt) -> JSStmt:
233
+
234
+ def emit_stmt(self, node: ast.stmt) -> Stmt:
144
235
  """Emit a statement."""
145
236
  if isinstance(node, ast.Return):
146
- return JSReturn(self.emit_expr(node.value))
237
+ value = self.emit_expr(node.value) if node.value else None
238
+ return Return(value)
147
239
 
148
240
  if isinstance(node, ast.Break):
149
- return JSBreak()
241
+ return Break()
150
242
 
151
243
  if isinstance(node, ast.Continue):
152
- return JSContinue()
244
+ return Continue()
153
245
 
154
246
  if isinstance(node, ast.Pass):
155
- # Pass is a no-op, emit empty statement
156
- return JSMultiStmt([])
247
+ # Pass is a no-op, emit empty block
248
+ return Block([])
157
249
 
158
250
  if isinstance(node, ast.AugAssign):
159
251
  if not isinstance(node.target, ast.Name):
160
- raise JSCompilationError("Only simple augmented assignments supported")
252
+ raise TranspileError(
253
+ "Only simple augmented assignments supported", node=node
254
+ )
161
255
  target = node.target.id
162
256
  op_type = type(node.op)
163
257
  if op_type not in ALLOWED_BINOPS:
164
- raise JSCompilationError(
165
- f"Unsupported augmented assignment operator: {op_type.__name__}"
258
+ raise TranspileError(
259
+ f"Unsupported augmented assignment operator: {op_type.__name__}",
260
+ node=node,
166
261
  )
167
262
  value_expr = self.emit_expr(node.value)
168
- return JSAugAssign(target, ALLOWED_BINOPS[op_type], value_expr)
263
+ return Assign(target, value_expr, op=ALLOWED_BINOPS[op_type])
169
264
 
170
265
  if isinstance(node, ast.Assign):
171
266
  if len(node.targets) != 1:
172
- raise JSCompilationError(
173
- "Multiple assignment targets are not supported"
267
+ raise TranspileError(
268
+ "Multiple assignment targets not supported", node=node
174
269
  )
175
270
  target_node = node.targets[0]
176
271
 
@@ -179,47 +274,44 @@ class JsTranspiler(ast.NodeVisitor):
179
274
  return self._emit_unpacking_assign(target_node, node.value)
180
275
 
181
276
  if not isinstance(target_node, ast.Name):
182
- raise JSCompilationError(
183
- "Only simple assignments to local names are supported"
277
+ raise TranspileError(
278
+ "Only simple assignments to local names supported", node=node
184
279
  )
185
280
 
186
281
  target = target_node.id
187
282
  value_expr = self.emit_expr(node.value)
188
283
 
189
284
  if target in self.locals:
190
- return JSAssign(target, value_expr, declare=False)
285
+ return Assign(target, value_expr)
191
286
  else:
192
287
  self.locals.add(target)
193
- return JSAssign(target, value_expr, declare=True)
288
+ return Assign(target, value_expr, declare="let")
194
289
 
195
290
  if isinstance(node, ast.AnnAssign):
196
291
  if not isinstance(node.target, ast.Name):
197
- raise JSCompilationError("Only simple annotated assignments supported")
292
+ raise TranspileError("Only simple annotated assignments supported")
198
293
  target = node.target.id
199
- value = JSUndefined() if node.value is None else self.emit_expr(node.value)
294
+ value = Literal(None) if node.value is None else self.emit_expr(node.value)
200
295
  if target in self.locals:
201
- return JSAssign(target, value, declare=False)
296
+ return Assign(target, value)
202
297
  else:
203
298
  self.locals.add(target)
204
- return JSAssign(target, value, declare=True)
299
+ return Assign(target, value, declare="let")
205
300
 
206
301
  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)
302
+ cond = self.emit_expr(node.test)
303
+ then = [self.emit_stmt(s) for s in node.body]
304
+ else_ = [self.emit_stmt(s) for s in node.orelse]
305
+ return If(cond, then, else_)
211
306
 
212
307
  if isinstance(node, ast.Expr):
213
308
  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)
309
+ return ExprStmt(expr)
218
310
 
219
311
  if isinstance(node, ast.While):
220
- test = self.emit_expr(node.test)
312
+ cond = self.emit_expr(node.test)
221
313
  body = [self.emit_stmt(s) for s in node.body]
222
- return JSWhile(test, body)
314
+ return While(cond, body)
223
315
 
224
316
  if isinstance(node, ast.For):
225
317
  return self._emit_for_loop(node)
@@ -227,93 +319,138 @@ class JsTranspiler(ast.NodeVisitor):
227
319
  if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
228
320
  return self._emit_nested_function(node)
229
321
 
230
- raise JSCompilationError(f"Unsupported statement: {type(node).__name__}")
322
+ if isinstance(node, ast.Try):
323
+ return self._emit_try(node)
324
+
325
+ if isinstance(node, ast.Raise):
326
+ return self._emit_raise(node)
327
+
328
+ raise TranspileError(f"Unsupported statement: {type(node).__name__}", node=node)
231
329
 
232
330
  def _emit_unpacking_assign(
233
331
  self, target: ast.Tuple | ast.List, value: ast.expr
234
- ) -> JSStmt:
332
+ ) -> Stmt:
235
333
  """Emit unpacking assignment: a, b, c = expr"""
236
334
  elements = target.elts
237
335
  if not elements or not all(isinstance(e, ast.Name) for e in elements):
238
- raise JSCompilationError("Unpacking is only supported for simple variables")
336
+ raise TranspileError("Unpacking only supported for simple variables")
239
337
 
240
338
  tmp_name = self._fresh_temp()
241
339
  value_expr = self.emit_expr(value)
242
- stmts: list[JSStmt] = [JSConstAssign(tmp_name, value_expr)]
340
+ stmts: list[Stmt] = [Assign(tmp_name, value_expr, declare="const")]
243
341
 
244
342
  for idx, e in enumerate(elements):
245
343
  assert isinstance(e, ast.Name)
246
344
  name = e.id
247
- sub = JSSubscript(JSIdentifier(tmp_name), JSNumber(idx))
345
+ sub = Subscript(Identifier(tmp_name), Literal(idx))
248
346
  if name in self.locals:
249
- stmts.append(JSAssign(name, sub, declare=False))
347
+ stmts.append(Assign(name, sub))
250
348
  else:
251
349
  self.locals.add(name)
252
- stmts.append(JSAssign(name, sub, declare=True))
350
+ stmts.append(Assign(name, sub, declare="let"))
253
351
 
254
- return JSMultiStmt(stmts)
352
+ return StmtSequence(stmts)
255
353
 
256
- def _emit_for_loop(self, node: ast.For) -> JSStmt:
354
+ def _emit_for_loop(self, node: ast.For) -> Stmt:
257
355
  """Emit a for loop."""
258
356
  # Handle tuple unpacking in for target
259
357
  if isinstance(node.target, (ast.Tuple, ast.List)):
260
358
  names: list[str] = []
261
359
  for e in node.target.elts:
262
360
  if not isinstance(e, ast.Name):
263
- raise JSCompilationError(
361
+ raise TranspileError(
264
362
  "Only simple name targets supported in for-loop unpacking"
265
363
  )
266
364
  names.append(e.id)
267
365
  self.locals.add(e.id)
268
366
  iter_expr = self.emit_expr(node.iter)
269
367
  body = [self.emit_stmt(s) for s in node.body]
270
- return JSForOf(names, iter_expr, body)
368
+ # Use array pattern for destructuring
369
+ target = f"[{', '.join(names)}]"
370
+ return ForOf(target, iter_expr, body)
271
371
 
272
372
  if not isinstance(node.target, ast.Name):
273
- raise JSCompilationError("Only simple name targets supported in for-loops")
373
+ raise TranspileError("Only simple name targets supported in for-loops")
274
374
 
275
375
  target = node.target.id
276
376
  self.locals.add(target)
277
377
  iter_expr = self.emit_expr(node.iter)
278
378
  body = [self.emit_stmt(s) for s in node.body]
279
- return JSForOf(target, iter_expr, body)
379
+ return ForOf(target, iter_expr, body)
280
380
 
281
381
  def _emit_nested_function(
282
382
  self, node: ast.FunctionDef | ast.AsyncFunctionDef
283
- ) -> JSStmt:
383
+ ) -> Stmt:
284
384
  """Emit a nested function definition."""
285
385
  name = node.name
286
386
  params = [arg.arg for arg in node.args.args]
287
387
 
288
- # Save current locals and extend with params (closure captures outer scope)
388
+ # Save current locals and extend with params
289
389
  saved_locals = set(self.locals)
290
390
  self.locals.update(params)
291
391
 
292
392
  # 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))
393
+ body_stmts = node.body
394
+ if (
395
+ body_stmts
396
+ and isinstance(body_stmts[0], ast.Expr)
397
+ and isinstance(body_stmts[0].value, ast.Constant)
398
+ and isinstance(body_stmts[0].value.value, str)
399
+ ):
400
+ body_stmts = body_stmts[1:]
401
+
402
+ stmts: list[Stmt] = [self.emit_stmt(s) for s in body_stmts]
303
403
 
304
404
  # Restore outer locals and add function name
305
405
  self.locals = saved_locals
306
406
  self.locals.add(name)
307
407
 
308
408
  is_async = isinstance(node, ast.AsyncFunctionDef)
309
- fn = JSFunctionDef(params, stmts, name=None, is_async=is_async)
310
- return JSConstAssign(name, fn)
409
+ fn = Function(params, stmts, is_async=is_async)
410
+ return Assign(name, fn, declare="const")
411
+
412
+ def _emit_try(self, node: ast.Try) -> Stmt:
413
+ """Emit a try/except/finally statement."""
414
+ body = [self.emit_stmt(s) for s in node.body]
415
+
416
+ # Handle except handlers - JS only supports single catch
417
+ catch_param: str | None = None
418
+ catch_body: list[Stmt] | None = None
419
+
420
+ if node.handlers:
421
+ if len(node.handlers) > 1:
422
+ raise TranspileError(
423
+ "Multiple except clauses not supported; JS only has one catch block",
424
+ node=node.handlers[1],
425
+ )
426
+ handler = node.handlers[0]
427
+ if handler.name:
428
+ catch_param = handler.name
429
+ self.locals.add(catch_param)
430
+ catch_body = [self.emit_stmt(s) for s in handler.body]
431
+
432
+ # Handle finally
433
+ finally_body: list[Stmt] | None = None
434
+ if node.finalbody:
435
+ finally_body = [self.emit_stmt(s) for s in node.finalbody]
436
+
437
+ return TryStmt(body, catch_param, catch_body, finally_body)
438
+
439
+ def _emit_raise(self, node: ast.Raise) -> Stmt:
440
+ """Emit a raise statement as throw."""
441
+ if node.exc is None:
442
+ raise TranspileError(
443
+ "Bare raise not supported; use explicit 'raise e' instead", node=node
444
+ )
445
+
446
+ return Throw(self.emit_expr(node.exc))
311
447
 
312
448
  # --- Expressions ---------------------------------------------------------
313
- def emit_expr(self, node: ast.expr | None) -> JSExpr:
449
+
450
+ def emit_expr(self, node: ast.expr | None) -> Expr:
314
451
  """Emit an expression."""
315
452
  if node is None:
316
- return JSNull()
453
+ return Literal(None)
317
454
 
318
455
  if isinstance(node, ast.Constant):
319
456
  return self._emit_constant(node)
@@ -328,8 +465,9 @@ class JsTranspiler(ast.NodeVisitor):
328
465
  return self._emit_dict(node)
329
466
 
330
467
  if isinstance(node, ast.Set):
331
- return JSNew(
332
- JSIdentifier("Set"), [JSArray([self.emit_expr(e) for e in node.elts])]
468
+ return Call(
469
+ Identifier("Set"),
470
+ [Array([self.emit_expr(e) for e in node.elts])],
333
471
  )
334
472
 
335
473
  if isinstance(node, ast.BinOp):
@@ -339,17 +477,17 @@ class JsTranspiler(ast.NodeVisitor):
339
477
  return self._emit_unaryop(node)
340
478
 
341
479
  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])
480
+ return self._emit_boolop(node)
344
481
 
345
482
  if isinstance(node, ast.Compare):
346
483
  return self._emit_compare(node)
347
484
 
348
485
  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)
486
+ return Ternary(
487
+ self.emit_expr(node.test),
488
+ self.emit_expr(node.body),
489
+ self.emit_expr(node.orelse),
490
+ )
353
491
 
354
492
  if isinstance(node, ast.Call):
355
493
  return self._emit_call(node)
@@ -377,113 +515,134 @@ class JsTranspiler(ast.NodeVisitor):
377
515
  arr = self._emit_comprehension_chain(
378
516
  node.generators, lambda: self.emit_expr(node.elt)
379
517
  )
380
- return JSNew(JSIdentifier("Set"), [arr])
518
+ return Call(Identifier("Set"), [arr])
381
519
 
382
520
  if isinstance(node, ast.DictComp):
383
521
  pairs = self._emit_comprehension_chain(
384
522
  node.generators,
385
- lambda: JSArray([self.emit_expr(node.key), self.emit_expr(node.value)]),
523
+ lambda: Array([self.emit_expr(node.key), self.emit_expr(node.value)]),
386
524
  )
387
- return JSNew(JSIdentifier("Map"), [pairs])
525
+ return Call(Identifier("Map"), [pairs])
388
526
 
389
527
  if isinstance(node, ast.Lambda):
390
528
  return self._emit_lambda(node)
391
529
 
392
530
  if isinstance(node, ast.Starred):
393
- return JSSpread(self.emit_expr(node.value))
531
+ return Spread(self.emit_expr(node.value))
394
532
 
395
533
  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))
534
+ return Unary("await", self.emit_expr(node.value))
399
535
 
400
- raise JSCompilationError(f"Unsupported expression: {type(node).__name__}")
536
+ raise TranspileError(
537
+ f"Unsupported expression: {type(node).__name__}", node=node
538
+ )
401
539
 
402
- def _emit_constant(self, node: ast.Constant) -> JSExpr:
540
+ def _emit_constant(self, node: ast.Constant) -> Expr:
403
541
  """Emit a constant value."""
404
542
  v = node.value
405
543
  if isinstance(v, str):
406
544
  # Use template literals for strings with Unicode line separators
407
545
  if "\u2028" in v or "\u2029" in v:
408
- return JSTemplate([v])
409
- return JSString(v)
546
+ return Template([v])
547
+ return Literal(v)
410
548
  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)
549
+ return Literal(None)
550
+ if isinstance(v, bool):
551
+ return Literal(v)
416
552
  if isinstance(v, (int, float)):
417
- return JSNumber(v)
418
- raise JSCompilationError(f"Unsupported constant type: {type(v).__name__}")
553
+ return Literal(v)
554
+ raise TranspileError(f"Unsupported constant type: {type(v).__name__}")
419
555
 
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
- """
556
+ def _emit_name(self, node: ast.Name) -> Expr:
557
+ """Emit a name reference."""
425
558
  name = node.id
426
559
 
427
- # Check deps first - all are JSExpr
560
+ # Check deps first
428
561
  if name in self.deps:
429
562
  return self.deps[name]
430
563
 
431
564
  # Local variable
432
565
  if name in self.locals:
433
- return JSIdentifier(name)
566
+ return Identifier(name)
567
+
568
+ # Check builtins
569
+ if name in BUILTINS:
570
+ return BUILTINS[name]
434
571
 
435
- raise JSCompilationError(f"Unbound name referenced: {name}")
572
+ raise TranspileError(f"Unbound name referenced: {name}", node=node)
436
573
 
437
- def _emit_list_or_tuple(self, node: ast.List | ast.Tuple) -> JSExpr:
574
+ def _emit_list_or_tuple(self, node: ast.List | ast.Tuple) -> Expr:
438
575
  """Emit a list or tuple literal."""
439
- parts: list[JSExpr] = []
576
+ parts: list[Expr] = []
440
577
  for e in node.elts:
441
578
  if isinstance(e, ast.Starred):
442
- parts.append(JSSpread(self.emit_expr(e.value)))
579
+ parts.append(Spread(self.emit_expr(e.value)))
443
580
  else:
444
581
  parts.append(self.emit_expr(e))
445
- return JSArray(parts)
582
+ return Array(parts)
446
583
 
447
- def _emit_dict(self, node: ast.Dict) -> JSExpr:
584
+ def _emit_dict(self, node: ast.Dict) -> Expr:
448
585
  """Emit a dict literal as new Map([...])."""
449
- entries: list[JSExpr] = []
586
+ entries: list[Expr] = []
450
587
  for k, v in zip(node.keys, node.values, strict=False):
451
588
  if k is None:
452
589
  # Spread merge
453
590
  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)))
591
+ is_map = Binary(vexpr, "instanceof", Identifier("Map"))
592
+ map_entries = Call(Member(vexpr, "entries"), [])
593
+ obj_entries = Call(Member(Identifier("Object"), "entries"), [vexpr])
594
+ entries.append(Spread(Ternary(is_map, map_entries, obj_entries)))
460
595
  continue
461
596
  key_expr = self.emit_expr(k)
462
597
  val_expr = self.emit_expr(v)
463
- entries.append(JSArray([key_expr, val_expr]))
464
- return JSNew(JSIdentifier("Map"), [JSArray(entries)])
598
+ entries.append(Array([key_expr, val_expr]))
599
+ return Call(Identifier("Map"), [Array(entries)])
465
600
 
466
- def _emit_binop(self, node: ast.BinOp) -> JSExpr:
601
+ def _emit_binop(self, node: ast.BinOp) -> Expr:
467
602
  """Emit a binary operation."""
468
603
  op = type(node.op)
604
+
605
+ # Special case: floor division -> Math.floor(x / y)
606
+ if op is ast.FloorDiv:
607
+ left = self.emit_expr(node.left)
608
+ right = self.emit_expr(node.right)
609
+ return Call(
610
+ Member(Identifier("Math"), "floor"),
611
+ [Binary(left, "/", right)],
612
+ )
613
+
469
614
  if op not in ALLOWED_BINOPS:
470
- raise JSCompilationError(f"Unsupported binary operator: {op.__name__}")
615
+ raise TranspileError(
616
+ f"Unsupported binary operator: {op.__name__}", node=node
617
+ )
471
618
  left = self.emit_expr(node.left)
472
619
  right = self.emit_expr(node.right)
473
- return JSBinary(left, ALLOWED_BINOPS[op], right)
620
+ return Binary(left, ALLOWED_BINOPS[op], right)
474
621
 
475
- def _emit_unaryop(self, node: ast.UnaryOp) -> JSExpr:
622
+ def _emit_unaryop(self, node: ast.UnaryOp) -> Expr:
476
623
  """Emit a unary operation."""
477
624
  op = type(node.op)
478
625
  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:
626
+ raise TranspileError(
627
+ f"Unsupported unary operator: {op.__name__}", node=node
628
+ )
629
+ return Unary(ALLOWED_UNOPS[op], self.emit_expr(node.operand))
630
+
631
+ def _emit_boolop(self, node: ast.BoolOp) -> Expr:
632
+ """Emit a boolean operation (and/or chain)."""
633
+ op = "&&" if isinstance(node.op, ast.And) else "||"
634
+ values = [self.emit_expr(v) for v in node.values]
635
+ # Build binary chain: a && b && c -> Binary(Binary(a, &&, b), &&, c)
636
+ result = values[0]
637
+ for v in values[1:]:
638
+ result = Binary(result, op, v)
639
+ return result
640
+
641
+ def _emit_compare(self, node: ast.Compare) -> Expr:
483
642
  """Emit a comparison expression."""
484
643
  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] = []
644
+ exprs: list[Expr] = [self.emit_expr(e) for e in operands]
645
+ cmp_parts: list[Expr] = []
487
646
 
488
647
  for i, op in enumerate(node.ops):
489
648
  left_node = operands[i]
@@ -496,16 +655,21 @@ class JsTranspiler(ast.NodeVisitor):
496
655
 
497
656
  if len(cmp_parts) == 1:
498
657
  return cmp_parts[0]
499
- return JSLogicalChain("&&", cmp_parts)
658
+
659
+ # Chain with &&
660
+ result = cmp_parts[0]
661
+ for v in cmp_parts[1:]:
662
+ result = Binary(result, "&&", v)
663
+ return result
500
664
 
501
665
  def _build_comparison(
502
666
  self,
503
- left_expr: JSExpr,
667
+ left_expr: Expr,
504
668
  left_node: ast.expr,
505
669
  op: ast.cmpop,
506
- right_expr: JSExpr,
670
+ right_expr: Expr,
507
671
  right_node: ast.expr,
508
- ) -> JSExpr:
672
+ ) -> Expr:
509
673
  """Build a single comparison."""
510
674
  # Identity comparisons
511
675
  if isinstance(op, (ast.Is, ast.IsNot)):
@@ -515,8 +679,8 @@ class JsTranspiler(ast.NodeVisitor):
515
679
  isinstance(left_node, ast.Constant) and left_node.value is None
516
680
  ):
517
681
  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)
682
+ return Binary(expr, "!=" if is_not else "==", Literal(None))
683
+ return Binary(left_expr, "!==" if is_not else "===", right_expr)
520
684
 
521
685
  # Membership tests
522
686
  if isinstance(op, (ast.In, ast.NotIn)):
@@ -527,140 +691,111 @@ class JsTranspiler(ast.NodeVisitor):
527
691
  # Standard comparisons
528
692
  op_type = type(op)
529
693
  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)
694
+ raise TranspileError(f"Unsupported comparison operator: {op_type.__name__}")
695
+ return Binary(left_expr, ALLOWED_CMPOPS[op_type], right_expr)
534
696
 
535
- def _build_membership_test(
536
- self, item: JSExpr, container: JSExpr, negate: bool
537
- ) -> JSExpr:
697
+ def _build_membership_test(self, item: Expr, container: Expr, negate: bool) -> Expr:
538
698
  """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"))
699
+ is_string = Binary(Unary("typeof", container), "===", Literal("string"))
700
+ is_array = Call(Member(Identifier("Array"), "isArray"), [container])
701
+ is_set = Binary(container, "instanceof", Identifier("Set"))
702
+ is_map = Binary(container, "instanceof", Identifier("Map"))
543
703
 
544
- is_array_or_string = JSLogicalChain("||", [is_array, is_string])
545
- is_set_or_map = JSLogicalChain("||", [is_set, is_map])
704
+ is_array_or_string = Binary(is_array, "||", is_string)
705
+ is_set_or_map = Binary(is_set, "||", is_map)
546
706
 
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)
707
+ has_array_or_string = Call(Member(container, "includes"), [item])
708
+ has_set_or_map = Call(Member(container, "has"), [item])
709
+ has_obj = Binary(item, "in", container)
550
710
 
551
- membership_expr = JSTertiary(
711
+ membership_expr = Ternary(
552
712
  is_array_or_string,
553
713
  has_array_or_string,
554
- JSTertiary(is_set_or_map, has_set_or_map, has_obj),
714
+ Ternary(is_set_or_map, has_set_or_map, has_obj),
555
715
  )
556
716
 
557
717
  if negate:
558
- return JSUnary("!", membership_expr)
718
+ return Unary("!", membership_expr)
559
719
  return membership_expr
560
720
 
561
- def _emit_call(self, node: ast.Call) -> JSExpr:
562
- """Emit a function call.
721
+ def _emit_call(self, node: ast.Call) -> Expr:
722
+ """Emit a function call."""
723
+ # Collect args and kwargs as raw AST values
724
+ args_raw = list(node.args)
725
+ kwargs_raw: dict[str, Any] = {}
726
+ for kw in node.keywords:
727
+ if kw.arg is None:
728
+ raise TranspileError(
729
+ "Spread props (**kwargs) not yet supported in v2 transpiler"
730
+ )
731
+ kwargs_raw[kw.arg] = kw.value
563
732
 
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)
733
+ # Method call: obj.method(args) - try builtin method dispatch
580
734
  if isinstance(node.func, ast.Attribute):
581
735
  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
736
+ method = node.func.attr
737
+ args: list[Expr] = [self.emit_expr(a) for a in args_raw]
738
+ kwargs: dict[str, Expr] = {
739
+ k: self.emit_expr(v) for k, v in kwargs_raw.items()
740
+ }
741
+
742
+ # Try builtin method handling with runtime checks
743
+ result = emit_method(obj, method, args, kwargs)
744
+ if result is not None:
745
+ return result
746
+
747
+ # IMPORTANT: derive method expr via transpile_getattr
748
+ method_expr = obj.transpile_getattr(method, self)
749
+ return method_expr.transpile_call(args_raw, kwargs_raw, self)
750
+
751
+ # Function call (or other callable expr)
586
752
  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.
753
+ return callee.transpile_call(args_raw, kwargs_raw, self)
613
754
 
614
- All behavior is encoded in JSExpr.emit_getattr hooks.
615
- """
755
+ def _emit_attribute(self, node: ast.Attribute) -> Expr:
756
+ """Emit an attribute access."""
616
757
  value = self.emit_expr(node.value)
617
- return value.emit_getattr(node.attr)
758
+ # Delegate to Expr.transpile_getattr (default returns Member)
759
+ return value.transpile_getattr(node.attr, self)
618
760
 
619
- def _emit_subscript(self, node: ast.Subscript) -> JSExpr:
761
+ def _emit_subscript(self, node: ast.Subscript) -> Expr:
620
762
  """Emit a subscript expression."""
621
763
  value = self.emit_expr(node.value)
622
764
 
623
- # Slice handling (not passed through emit_subscript hook)
765
+ # Slice handling
624
766
  if isinstance(node.slice, ast.Slice):
625
767
  return self._emit_slice(value, node.slice)
626
768
 
627
- # Negative index: use .at() (not passed through emit_subscript hook)
769
+ # Negative index: use .at()
628
770
  if isinstance(node.slice, ast.UnaryOp) and isinstance(node.slice.op, ast.USub):
629
771
  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)]
772
+ return Call(Member(value, "at"), [Unary("-", idx_expr)])
638
773
 
639
- # Use emit_subscript hook for extensibility
640
- return value.emit_subscript(indices)
774
+ # Delegate to Expr.transpile_subscript (default returns Subscript)
775
+ return value.transpile_subscript(node.slice, self)
641
776
 
642
- def _emit_slice(self, value: JSExpr, slice_node: ast.Slice) -> JSExpr:
777
+ def _emit_slice(self, value: Expr, slice_node: ast.Slice) -> Expr:
643
778
  """Emit a slice operation."""
644
779
  if slice_node.step is not None:
645
- raise JSCompilationError("Slice steps are not supported")
780
+ raise TranspileError("Slice steps are not supported")
646
781
 
647
782
  lower = slice_node.lower
648
783
  upper = slice_node.upper
649
784
 
650
785
  if lower is None and upper is None:
651
- return JSMemberCall(value, "slice", [])
786
+ return Call(Member(value, "slice"), [])
652
787
  elif lower is None:
653
- return JSMemberCall(value, "slice", [JSNumber(0), self.emit_expr(upper)])
788
+ return Call(Member(value, "slice"), [Literal(0), self.emit_expr(upper)])
654
789
  elif upper is None:
655
- return JSMemberCall(value, "slice", [self.emit_expr(lower)])
790
+ return Call(Member(value, "slice"), [self.emit_expr(lower)])
656
791
  else:
657
- return JSMemberCall(
658
- value, "slice", [self.emit_expr(lower), self.emit_expr(upper)]
792
+ return Call(
793
+ Member(value, "slice"), [self.emit_expr(lower), self.emit_expr(upper)]
659
794
  )
660
795
 
661
- def _emit_fstring(self, node: ast.JoinedStr) -> JSExpr:
796
+ def _emit_fstring(self, node: ast.JoinedStr) -> Expr:
662
797
  """Emit an f-string as a template literal."""
663
- parts: list[str | JSExpr] = []
798
+ parts: list[str | Expr] = []
664
799
  for part in node.values:
665
800
  if isinstance(part, ast.Constant) and isinstance(part.value, str):
666
801
  parts.append(part.value)
@@ -668,65 +803,53 @@ class JsTranspiler(ast.NodeVisitor):
668
803
  expr = self.emit_expr(part.value)
669
804
  # Handle conversion flags: !s, !r, !a
670
805
  if part.conversion == ord("s"):
671
- expr = JSCall(JSIdentifier("String"), [expr])
806
+ expr = Call(Identifier("String"), [expr])
672
807
  elif part.conversion == ord("r"):
673
- expr = JSCall(JSMember(JSIdentifier("JSON"), "stringify"), [expr])
808
+ expr = Call(Member(Identifier("JSON"), "stringify"), [expr])
674
809
  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)
810
+ expr = Call(Member(Identifier("JSON"), "stringify"), [expr])
811
+ # Handle format_spec
678
812
  if part.format_spec is not None:
679
813
  if not isinstance(part.format_spec, ast.JoinedStr):
680
- raise JSCompilationError("Format spec must be a JoinedStr")
814
+ raise TranspileError("Format spec must be a JoinedStr")
681
815
  expr = self._apply_format_spec(expr, part.format_spec)
682
816
  parts.append(expr)
683
817
  else:
684
- raise JSCompilationError(
818
+ raise TranspileError(
685
819
  f"Unsupported f-string component: {type(part).__name__}"
686
820
  )
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)
821
+ return Template(parts)
822
+
823
+ def _apply_format_spec(self, expr: Expr, format_spec: ast.JoinedStr) -> Expr:
824
+ """Apply a Python format spec to an expression."""
702
825
  if len(format_spec.values) != 1:
703
- raise JSCompilationError("Dynamic format specs not supported")
826
+ raise TranspileError("Dynamic format specs not supported")
704
827
  spec_part = format_spec.values[0]
705
828
  if not isinstance(spec_part, ast.Constant) or not isinstance(
706
829
  spec_part.value, str
707
830
  ):
708
- raise JSCompilationError("Dynamic format specs not supported")
831
+ raise TranspileError("Dynamic format specs not supported")
709
832
 
710
833
  spec = spec_part.value
711
834
  return self._parse_and_apply_format(expr, spec)
712
835
 
713
- def _parse_and_apply_format(self, expr: JSExpr, spec: str) -> JSExpr:
836
+ def _parse_and_apply_format(self, expr: Expr, spec: str) -> Expr:
714
837
  """Parse a format spec string and apply it to expr."""
715
838
  if not spec:
716
839
  return expr
717
840
 
718
- # Parse Python format spec: [[fill]align][sign][#][0][width][,][.precision][type]
841
+ # Parse Python format spec
719
842
  pattern = r"^([^<>=^]?[<>=^])?([+\- ])?([#])?(0)?(\d+)?([,_])?(\.(\d+))?([bcdeEfFgGnosxX%])?$"
720
843
  match = re.match(pattern, spec)
721
844
  if not match:
722
- raise JSCompilationError(f"Unsupported format spec: {spec!r}")
845
+ raise TranspileError(f"Unsupported format spec: {spec!r}")
723
846
 
724
847
  align_part = match.group(1) or ""
725
848
  sign = match.group(2) or ""
726
- alt_form = match.group(3) # '#'
727
- zero_pad = match.group(4) # '0'
849
+ alt_form = match.group(3)
850
+ zero_pad = match.group(4)
728
851
  width_str = match.group(5)
729
- # thousands_sep = match.group(6) # ',' or '_' - not commonly needed
852
+ grouping = match.group(6) or ""
730
853
  precision_str = match.group(8)
731
854
  type_char = match.group(9) or ""
732
855
 
@@ -746,126 +869,145 @@ class JsTranspiler(ast.NodeVisitor):
746
869
 
747
870
  # Handle type conversions first
748
871
  if type_char in ("f", "F"):
749
- # Float with precision
750
872
  prec = precision if precision is not None else 6
751
- expr = JSMemberCall(expr, "toFixed", [JSNumber(prec)])
873
+ expr = Call(Member(expr, "toFixed"), [Literal(prec)])
752
874
  if sign == "+":
753
- # Add sign prefix for positive numbers
754
- expr = JSTertiary(
755
- JSBinary(expr, ">=", JSNumber(0)),
756
- JSBinary(JSString("+"), "+", expr),
875
+ expr = Ternary(
876
+ Binary(expr, ">=", Literal(0)),
877
+ Binary(Literal("+"), "+", expr),
757
878
  expr,
758
879
  )
759
880
  elif type_char == "d":
760
- # Integer - convert to string for padding (only if we need padding later)
761
881
  if width is not None:
762
- expr = JSCall(JSIdentifier("String"), [expr])
882
+ expr = Call(Identifier("String"), [expr])
763
883
  elif type_char == "x":
764
- # Hex lowercase
765
- base_expr = JSMemberCall(expr, "toString", [JSNumber(16)])
884
+ base_expr = Call(Member(expr, "toString"), [Literal(16)])
766
885
  if alt_form:
767
- expr = JSBinary(JSString("0x"), "+", base_expr)
886
+ expr = Binary(Literal("0x"), "+", base_expr)
768
887
  else:
769
888
  expr = base_expr
770
889
  elif type_char == "X":
771
- # Hex uppercase
772
- base_expr = JSMemberCall(
773
- JSMemberCall(expr, "toString", [JSNumber(16)]), "toUpperCase", []
890
+ base_expr = Call(
891
+ Member(Call(Member(expr, "toString"), [Literal(16)]), "toUpperCase"), []
774
892
  )
775
893
  if alt_form:
776
- expr = JSBinary(JSString("0x"), "+", base_expr)
894
+ expr = Binary(Literal("0x"), "+", base_expr)
777
895
  else:
778
896
  expr = base_expr
779
897
  elif type_char == "o":
780
- # Octal
781
- base_expr = JSMemberCall(expr, "toString", [JSNumber(8)])
898
+ base_expr = Call(Member(expr, "toString"), [Literal(8)])
782
899
  if alt_form:
783
- expr = JSBinary(JSString("0o"), "+", base_expr)
900
+ expr = Binary(Literal("0o"), "+", base_expr)
784
901
  else:
785
902
  expr = base_expr
786
903
  elif type_char == "b":
787
- # Binary
788
- base_expr = JSMemberCall(expr, "toString", [JSNumber(2)])
904
+ base_expr = Call(Member(expr, "toString"), [Literal(2)])
789
905
  if alt_form:
790
- expr = JSBinary(JSString("0b"), "+", base_expr)
906
+ expr = Binary(Literal("0b"), "+", base_expr)
791
907
  else:
792
908
  expr = base_expr
793
909
  elif type_char == "e":
794
- # Exponential notation lowercase
795
910
  prec = precision if precision is not None else 6
796
- expr = JSMemberCall(expr, "toExponential", [JSNumber(prec)])
911
+ expr = Call(Member(expr, "toExponential"), [Literal(prec)])
797
912
  elif type_char == "E":
798
- # Exponential notation uppercase
799
913
  prec = precision if precision is not None else 6
800
- expr = JSMemberCall(
801
- JSMemberCall(expr, "toExponential", [JSNumber(prec)]), "toUpperCase", []
914
+ expr = Call(
915
+ Member(
916
+ Call(Member(expr, "toExponential"), [Literal(prec)]), "toUpperCase"
917
+ ),
918
+ [],
802
919
  )
920
+ elif type_char == "g":
921
+ # General format: uses toPrecision
922
+ prec = precision if precision is not None else 6
923
+ expr = Call(Member(expr, "toPrecision"), [Literal(prec)])
924
+ elif type_char == "G":
925
+ # General format uppercase
926
+ prec = precision if precision is not None else 6
927
+ expr = Call(
928
+ Member(
929
+ Call(Member(expr, "toPrecision"), [Literal(prec)]), "toUpperCase"
930
+ ),
931
+ [],
932
+ )
933
+ elif type_char == "%":
934
+ # Percentage: multiply by 100, format as fixed, append %
935
+ prec = precision if precision is not None else 6
936
+ multiplied = Binary(expr, "*", Literal(100))
937
+ fixed = Call(Member(multiplied, "toFixed"), [Literal(prec)])
938
+ expr = Binary(fixed, "+", Literal("%"))
939
+ elif type_char == "c":
940
+ # Character: convert code point to character
941
+ expr = Call(Member(Identifier("String"), "fromCharCode"), [expr])
942
+ elif type_char == "n":
943
+ # Locale-aware number format
944
+ expr = Call(Member(expr, "toLocaleString"), [])
803
945
  elif type_char == "s" or type_char == "":
804
- # String - convert to string if not already
805
946
  if type_char == "s" or (width is not None and align):
806
- expr = JSCall(JSIdentifier("String"), [expr])
947
+ expr = Call(Identifier("String"), [expr])
948
+
949
+ # Apply thousand separator grouping
950
+ if grouping == ",":
951
+ # Use toLocaleString with en-US to get comma separators
952
+ expr = Call(Member(expr, "toLocaleString"), [Literal("en-US")])
953
+ elif grouping == "_":
954
+ # Use toLocaleString then replace commas with underscores
955
+ locale_expr = Call(Member(expr, "toLocaleString"), [Literal("en-US")])
956
+ expr = Call(
957
+ Member(locale_expr, "replace"), [Identifier(r"/,/g"), Literal("_")]
958
+ )
807
959
 
808
960
  # Apply width/padding
809
961
  if width is not None:
810
- fill_str = JSString(fill)
811
- width_num = JSNumber(width)
962
+ fill_str = Literal(fill)
963
+ width_num = Literal(width)
812
964
 
813
965
  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")],
966
+ expr = Call(
967
+ Member(Call(Identifier("String"), [expr]), "padStart"),
968
+ [width_num, Literal("0")],
822
969
  )
823
970
  elif align == "<":
824
- # Left align -> padEnd
825
- expr = JSMemberCall(expr, "padEnd", [width_num, fill_str])
971
+ expr = Call(Member(expr, "padEnd"), [width_num, fill_str])
826
972
  elif align == ">":
827
- # Right align -> padStart
828
- expr = JSMemberCall(expr, "padStart", [width_num, fill_str])
973
+ expr = Call(Member(expr, "padStart"), [width_num, fill_str])
829
974
  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),
975
+ # Center align
976
+ expr = Call(
977
+ Member(
978
+ Call(
979
+ Member(expr, "padStart"),
980
+ [
981
+ Binary(
982
+ Binary(
983
+ Binary(width_num, "+", Member(expr, "length")),
984
+ "/",
985
+ Literal(2),
986
+ ),
987
+ "|",
988
+ Literal(0),
842
989
  ),
843
- "|",
844
- JSNumber(0),
845
- ),
846
- fill_str,
847
- ],
990
+ fill_str,
991
+ ],
992
+ ),
993
+ "padEnd",
848
994
  ),
849
- "padEnd",
850
995
  [width_num, fill_str],
851
996
  )
852
997
  elif align == "=":
853
- # Pad after sign - not commonly used, treat as right align
854
- expr = JSMemberCall(expr, "padStart", [width_num, fill_str])
998
+ expr = Call(Member(expr, "padStart"), [width_num, fill_str])
855
999
  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")],
1000
+ expr = Call(
1001
+ Member(Call(Identifier("String"), [expr]), "padStart"),
1002
+ [width_num, Literal("0")],
861
1003
  )
862
1004
 
863
1005
  return expr
864
1006
 
865
- def _emit_lambda(self, node: ast.Lambda) -> JSExpr:
1007
+ def _emit_lambda(self, node: ast.Lambda) -> Expr:
866
1008
  """Emit a lambda expression as an arrow function."""
867
- # Get parameter names
868
1009
  params = [arg.arg for arg in node.args.args]
1010
+
869
1011
  # Add params to locals temporarily
870
1012
  saved_locals = set(self.locals)
871
1013
  self.locals.update(params)
@@ -874,43 +1016,41 @@ class JsTranspiler(ast.NodeVisitor):
874
1016
 
875
1017
  self.locals = saved_locals
876
1018
 
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)
1019
+ return Arrow(params, body)
883
1020
 
884
1021
  def _emit_comprehension_chain(
885
1022
  self,
886
1023
  generators: list[ast.comprehension],
887
- build_last: Callable[[], JSExpr],
888
- ) -> JSExpr:
1024
+ build_last: Callable[[], Expr],
1025
+ ) -> Expr:
889
1026
  """Build a flatMap/map chain for comprehensions."""
890
1027
  if len(generators) == 0:
891
- raise JSCompilationError("Empty comprehension")
1028
+ raise TranspileError("Empty comprehension")
892
1029
 
893
1030
  saved_locals = set(self.locals)
894
1031
 
895
- def build_chain(gen_index: int) -> JSExpr:
1032
+ def build_chain(gen_index: int) -> Expr:
896
1033
  gen = generators[gen_index]
897
1034
  if gen.is_async:
898
- raise JSCompilationError("Async comprehensions are not supported")
1035
+ raise TranspileError("Async comprehensions are not supported")
899
1036
 
900
1037
  iter_expr = self.emit_expr(gen.iter)
901
- # Get arrow function parameter code and variable names from a target
1038
+
1039
+ # Get parameter and variable names from target
902
1040
  if isinstance(gen.target, ast.Name):
903
- param_code = gen.target.id
1041
+ params = [gen.target.id]
904
1042
  names = [gen.target.id]
905
1043
  elif isinstance(gen.target, ast.Tuple) and all(
906
1044
  isinstance(e, ast.Name) for e in gen.target.elts
907
1045
  ):
908
1046
  names = [e.id for e in gen.target.elts if isinstance(e, ast.Name)]
909
- param_code = f"([{', '.join(names)}])"
1047
+ # For destructuring, use array pattern as single param: [a, b]
1048
+ params = [f"([{', '.join(names)}])"]
910
1049
  else:
911
- raise JSCompilationError(
1050
+ raise TranspileError(
912
1051
  "Only name or tuple targets supported in comprehensions"
913
1052
  )
1053
+
914
1054
  for nm in names:
915
1055
  self.locals.add(nm)
916
1056
 
@@ -919,20 +1059,36 @@ class JsTranspiler(ast.NodeVisitor):
919
1059
  # Apply filters
920
1060
  if gen.ifs:
921
1061
  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)])
1062
+ cond = conds[0]
1063
+ for c in conds[1:]:
1064
+ cond = Binary(cond, "&&", c)
1065
+ base = Call(Member(base, "filter"), [Arrow(params, cond)])
924
1066
 
925
1067
  is_last = gen_index == len(generators) - 1
926
1068
  if is_last:
927
1069
  elt_expr = build_last()
928
- return JSMemberCall(
929
- base, "map", [JSArrowFunction(param_code, elt_expr)]
930
- )
1070
+ return Call(Member(base, "map"), [Arrow(params, elt_expr)])
931
1071
 
932
1072
  inner = build_chain(gen_index + 1)
933
- return JSMemberCall(base, "flatMap", [JSArrowFunction(param_code, inner)])
1073
+ return Call(Member(base, "flatMap"), [Arrow(params, inner)])
934
1074
 
935
1075
  try:
936
1076
  return build_chain(0)
937
1077
  finally:
938
1078
  self.locals = saved_locals
1079
+
1080
+
1081
+ def transpile(
1082
+ fndef: ast.FunctionDef | ast.AsyncFunctionDef,
1083
+ deps: Mapping[str, Expr] | None = None,
1084
+ ) -> Function | Arrow:
1085
+ """Transpile a Python function to a v2 Function or Arrow node.
1086
+
1087
+ Args:
1088
+ fndef: The function definition AST node
1089
+ deps: Dictionary mapping global names to Expr instances
1090
+
1091
+ Returns:
1092
+ Arrow for single-expression functions, Function for multi-statement
1093
+ """
1094
+ return Transpiler(fndef, deps or {}).transpile()