pulse-framework 0.1.44__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.
Files changed (80) hide show
  1. pulse/__init__.py +10 -24
  2. pulse/app.py +3 -25
  3. pulse/codegen/codegen.py +43 -88
  4. pulse/codegen/js.py +35 -5
  5. pulse/codegen/templates/route.py +341 -254
  6. pulse/form.py +1 -1
  7. pulse/helpers.py +40 -8
  8. pulse/hooks/core.py +2 -2
  9. pulse/hooks/effects.py +1 -1
  10. pulse/hooks/init.py +2 -1
  11. pulse/hooks/setup.py +1 -1
  12. pulse/hooks/stable.py +2 -2
  13. pulse/hooks/states.py +2 -2
  14. pulse/html/props.py +3 -2
  15. pulse/html/tags.py +135 -0
  16. pulse/html/tags.pyi +4 -0
  17. pulse/js/__init__.py +110 -0
  18. pulse/js/__init__.pyi +95 -0
  19. pulse/js/_types.py +297 -0
  20. pulse/js/array.py +253 -0
  21. pulse/js/console.py +47 -0
  22. pulse/js/date.py +113 -0
  23. pulse/js/document.py +138 -0
  24. pulse/js/error.py +139 -0
  25. pulse/js/json.py +62 -0
  26. pulse/js/map.py +84 -0
  27. pulse/js/math.py +66 -0
  28. pulse/js/navigator.py +76 -0
  29. pulse/js/number.py +54 -0
  30. pulse/js/object.py +173 -0
  31. pulse/js/promise.py +150 -0
  32. pulse/js/regexp.py +54 -0
  33. pulse/js/set.py +109 -0
  34. pulse/js/string.py +35 -0
  35. pulse/js/weakmap.py +50 -0
  36. pulse/js/weakset.py +45 -0
  37. pulse/js/window.py +199 -0
  38. pulse/messages.py +22 -3
  39. pulse/queries/client.py +7 -7
  40. pulse/queries/effect.py +16 -0
  41. pulse/queries/infinite_query.py +138 -29
  42. pulse/queries/mutation.py +1 -15
  43. pulse/queries/protocol.py +136 -0
  44. pulse/queries/query.py +610 -174
  45. pulse/queries/store.py +11 -14
  46. pulse/react_component.py +167 -14
  47. pulse/reactive.py +19 -1
  48. pulse/reactive_extensions.py +5 -5
  49. pulse/render_session.py +185 -59
  50. pulse/renderer.py +80 -158
  51. pulse/routing.py +1 -18
  52. pulse/transpiler/__init__.py +131 -0
  53. pulse/transpiler/builtins.py +731 -0
  54. pulse/transpiler/constants.py +110 -0
  55. pulse/transpiler/context.py +26 -0
  56. pulse/transpiler/errors.py +2 -0
  57. pulse/transpiler/function.py +250 -0
  58. pulse/transpiler/ids.py +16 -0
  59. pulse/transpiler/imports.py +409 -0
  60. pulse/transpiler/js_module.py +274 -0
  61. pulse/transpiler/modules/__init__.py +30 -0
  62. pulse/transpiler/modules/asyncio.py +38 -0
  63. pulse/transpiler/modules/json.py +20 -0
  64. pulse/transpiler/modules/math.py +320 -0
  65. pulse/transpiler/modules/re.py +466 -0
  66. pulse/transpiler/modules/tags.py +268 -0
  67. pulse/transpiler/modules/typing.py +59 -0
  68. pulse/transpiler/nodes.py +1216 -0
  69. pulse/transpiler/py_module.py +119 -0
  70. pulse/transpiler/transpiler.py +938 -0
  71. pulse/transpiler/utils.py +4 -0
  72. pulse/types/event_handler.py +3 -2
  73. pulse/vdom.py +212 -13
  74. {pulse_framework-0.1.44.dist-info → pulse_framework-0.1.47.dist-info}/METADATA +1 -1
  75. pulse_framework-0.1.47.dist-info/RECORD +119 -0
  76. pulse/codegen/imports.py +0 -204
  77. pulse/css.py +0 -155
  78. pulse_framework-0.1.44.dist-info/RECORD +0 -79
  79. {pulse_framework-0.1.44.dist-info → pulse_framework-0.1.47.dist-info}/WHEEL +0 -0
  80. {pulse_framework-0.1.44.dist-info → pulse_framework-0.1.47.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,1216 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ from abc import ABC, abstractmethod
5
+ from collections.abc import Callable, Sequence
6
+ from typing import Any, ClassVar, TypeVar, cast, overload, override
7
+
8
+ from pulse.transpiler.context import is_interpreted_mode
9
+ from pulse.transpiler.errors import JSCompilationError
10
+
11
+ # Global registry: id(value) -> JSExpr
12
+ # Used by JSExpr.of() to resolve registered Python values
13
+ JSEXPR_REGISTRY: dict[int, JSExpr] = {}
14
+
15
+ ALLOWED_BINOPS: dict[type[ast.operator], str] = {
16
+ ast.Add: "+",
17
+ ast.Sub: "-",
18
+ ast.Mult: "*",
19
+ ast.Div: "/",
20
+ ast.Mod: "%",
21
+ ast.Pow: "**",
22
+ }
23
+
24
+ ALLOWED_UNOPS: dict[type[ast.unaryop], str] = {
25
+ ast.UAdd: "+",
26
+ ast.USub: "-",
27
+ ast.Not: "!",
28
+ }
29
+
30
+ ALLOWED_CMPOPS: dict[type[ast.cmpop], str] = {
31
+ ast.Eq: "===",
32
+ ast.NotEq: "!==",
33
+ ast.Lt: "<",
34
+ ast.LtE: "<=",
35
+ ast.Gt: ">",
36
+ ast.GtE: ">=",
37
+ }
38
+
39
+
40
+ ###############################################################################
41
+ # JS AST
42
+ ###############################################################################
43
+
44
+
45
+ class JSNode(ABC):
46
+ __slots__ = () # pyright: ignore[reportUnannotatedClassAttribute]
47
+
48
+ @abstractmethod
49
+ def emit(self) -> str:
50
+ raise NotImplementedError
51
+
52
+
53
+ class JSExpr(JSNode, ABC):
54
+ """Base class for JavaScript expressions.
55
+
56
+ Subclasses can override emit_call, emit_subscript, and emit_getattr to
57
+ customize how the expression behaves when called, indexed, or accessed.
58
+ This enables extensibility for things like JSX elements.
59
+ """
60
+
61
+ __slots__ = () # pyright: ignore[reportUnannotatedClassAttribute]
62
+
63
+ # Set to True for expressions that emit JSX (should not be wrapped in {})
64
+ is_jsx: ClassVar[bool] = False
65
+
66
+ # Set to True for expressions that have primary precedence (identifiers, literals, etc.)
67
+ # Used by expr_precedence to determine if parenthesization is needed
68
+ is_primary: ClassVar[bool] = False
69
+
70
+ @classmethod
71
+ def of(cls, value: Any) -> JSExpr:
72
+ """Convert a Python value to a JSExpr.
73
+
74
+ Resolution order:
75
+ 1. Already a JSExpr: returned as-is
76
+ 2. Registered in JSEXPR_REGISTRY: return the registered expr
77
+ 3. Primitives: str->JSString, int/float->JSNumber, bool->JSBoolean, None->JSNull
78
+ 4. Collections: list/tuple->JSArray, dict->JSObjectExpr (recursively converted)
79
+ """
80
+ # Already a JSExpr
81
+ if isinstance(value, JSExpr):
82
+ return value
83
+
84
+ # Check registry (for modules, functions, etc.)
85
+ if (expr := JSEXPR_REGISTRY.get(id(value))) is not None:
86
+ return expr
87
+
88
+ # Primitives
89
+ if isinstance(value, str):
90
+ return JSString(value)
91
+ if isinstance(
92
+ value, bool
93
+ ): # Must check before int since bool is subclass of int
94
+ return JSBoolean(value)
95
+ if isinstance(value, (int, float)):
96
+ return JSNumber(value)
97
+ if value is None:
98
+ return JSNull()
99
+
100
+ # Collections
101
+ if isinstance(value, (list, tuple)):
102
+ return JSArray([cls.of(v) for v in value])
103
+ if isinstance(value, dict):
104
+ props = [JSProp(JSString(str(k)), cls.of(v)) for k, v in value.items()] # pyright: ignore[reportUnknownArgumentType]
105
+ return JSObjectExpr(props)
106
+
107
+ raise TypeError(f"Cannot convert {type(value).__name__} to JSExpr")
108
+
109
+ @classmethod
110
+ def register(cls, value: Any, expr: JSExpr | Callable[..., JSExpr]) -> None:
111
+ """Register a Python value for conversion via JSExpr.of().
112
+
113
+ Args:
114
+ value: The Python object to register (function, constant, etc.)
115
+ expr: Either a JSExpr or a Callable[..., JSExpr] (will be wrapped in JSTransformer)
116
+ """
117
+ if callable(expr) and not isinstance(expr, JSExpr):
118
+ expr = JSTransformer(expr)
119
+ JSEXPR_REGISTRY[id(value)] = expr
120
+
121
+ def emit_call(self, args: list[Any], kwargs: dict[str, Any]) -> JSExpr:
122
+ """Called when this expression is used as a function: expr(args).
123
+
124
+ Override to customize call behavior. Default converts args/kwargs to
125
+ JSExpr via JSExpr.of() and emits JSCall(self, args).
126
+ Rejects keyword arguments by default.
127
+
128
+ Args receive raw Python values. Use JSExpr.of() to convert as needed.
129
+
130
+ The kwargs dict maps prop names to values:
131
+ - "propName" -> value for named kwargs
132
+ - "$spread{N}" -> JSSpread(expr) for **spread kwargs (already JSExpr)
133
+
134
+ Dict order is preserved, so iteration order matches source order.
135
+ """
136
+ if kwargs:
137
+ raise JSCompilationError(
138
+ "Keyword arguments not supported in default function call"
139
+ )
140
+ return JSCall(self, [JSExpr.of(a) for a in args])
141
+
142
+ def emit_subscript(self, indices: list[Any]) -> JSExpr:
143
+ """Called when this expression is indexed: expr[a, b, c].
144
+
145
+ Override to customize subscript behavior. Default requires single index
146
+ and emits JSSubscript(self, index).
147
+
148
+ Args receive raw Python values. Use JSExpr.of() to convert as needed.
149
+ """
150
+ if len(indices) != 1:
151
+ raise JSCompilationError("Multiple indices not supported in subscript")
152
+ return JSSubscript(self, JSExpr.of(indices[0]))
153
+
154
+ def emit_getattr(self, attr: str) -> JSExpr:
155
+ """Called when an attribute is accessed: expr.attr.
156
+
157
+ Override to customize attribute access. Default emits JSMember(self, attr).
158
+ """
159
+ return JSMember(self, attr)
160
+
161
+ def __getattr__(self, attr: str) -> JSExpr:
162
+ """Support attribute access at Python runtime.
163
+
164
+ Allows: expr.attr where expr is any JSExpr.
165
+ Delegates to emit_getattr for transpilation.
166
+ """
167
+ return self.emit_getattr(attr)
168
+
169
+ def __call__(self, *args: Any, **kwargs: Any) -> JSExpr:
170
+ """Support function calls at Python runtime.
171
+
172
+ Allows: expr(*args, **kwargs) where expr is any JSExpr.
173
+ Delegates to emit_call for transpilation.
174
+ """
175
+ return self.emit_call(list(args), kwargs)
176
+
177
+ def __getitem__(self, key: Any) -> JSExpr:
178
+ """Support subscript access at Python runtime.
179
+
180
+ Allows: expr[key] where expr is any JSExpr.
181
+ Delegates to emit_subscript for transpilation.
182
+ """
183
+ return self.emit_subscript([key])
184
+
185
+
186
+ class JSStmt(JSNode, ABC):
187
+ __slots__ = () # pyright: ignore[reportUnannotatedClassAttribute]
188
+
189
+
190
+ class JSIdentifier(JSExpr):
191
+ __slots__ = ("name",) # pyright: ignore[reportUnannotatedClassAttribute]
192
+ is_primary: ClassVar[bool] = True
193
+ name: str
194
+
195
+ def __init__(self, name: str):
196
+ self.name = name
197
+
198
+ @override
199
+ def emit(self) -> str:
200
+ return self.name
201
+
202
+
203
+ class JSString(JSExpr):
204
+ __slots__ = ("value",) # pyright: ignore[reportUnannotatedClassAttribute]
205
+ is_primary: ClassVar[bool] = True
206
+ value: str
207
+
208
+ def __init__(self, value: str):
209
+ self.value = value
210
+
211
+ @override
212
+ def emit(self) -> str:
213
+ s = self.value
214
+ # Escape for double-quoted JS string literals
215
+ s = (
216
+ s.replace("\\", "\\\\")
217
+ .replace('"', '\\"')
218
+ .replace("\n", "\\n")
219
+ .replace("\r", "\\r")
220
+ .replace("\t", "\\t")
221
+ .replace("\b", "\\b")
222
+ .replace("\f", "\\f")
223
+ .replace("\v", "\\v")
224
+ .replace("\x00", "\\x00")
225
+ .replace("\u2028", "\\u2028")
226
+ .replace("\u2029", "\\u2029")
227
+ )
228
+ return f'"{s}"'
229
+
230
+
231
+ class JSNumber(JSExpr):
232
+ __slots__ = ("value",) # pyright: ignore[reportUnannotatedClassAttribute]
233
+ is_primary: ClassVar[bool] = True
234
+ value: int | float
235
+
236
+ def __init__(self, value: int | float):
237
+ self.value = value
238
+
239
+ @override
240
+ def emit(self) -> str:
241
+ return str(self.value)
242
+
243
+
244
+ class JSBoolean(JSExpr):
245
+ __slots__ = ("value",) # pyright: ignore[reportUnannotatedClassAttribute]
246
+ is_primary: ClassVar[bool] = True
247
+ value: bool
248
+
249
+ def __init__(self, value: bool):
250
+ self.value = value
251
+
252
+ @override
253
+ def emit(self) -> str:
254
+ return "true" if self.value else "false"
255
+
256
+
257
+ class JSNull(JSExpr):
258
+ __slots__ = () # pyright: ignore[reportUnannotatedClassAttribute]
259
+ is_primary: ClassVar[bool] = True
260
+
261
+ @override
262
+ def emit(self) -> str:
263
+ return "null"
264
+
265
+
266
+ class JSUndefined(JSExpr):
267
+ __slots__ = () # pyright: ignore[reportUnannotatedClassAttribute]
268
+ is_primary: ClassVar[bool] = True
269
+
270
+ @override
271
+ def emit(self) -> str:
272
+ return "undefined"
273
+
274
+
275
+ class JSArray(JSExpr):
276
+ __slots__ = ("elements",) # pyright: ignore[reportUnannotatedClassAttribute]
277
+ is_primary: ClassVar[bool] = True
278
+ elements: Sequence[JSExpr]
279
+
280
+ def __init__(self, elements: Sequence[JSExpr]):
281
+ self.elements = elements
282
+
283
+ @override
284
+ def emit(self) -> str:
285
+ inner = ", ".join(e.emit() for e in self.elements)
286
+ return f"[{inner}]"
287
+
288
+
289
+ class JSSpread(JSExpr):
290
+ __slots__ = ("expr",) # pyright: ignore[reportUnannotatedClassAttribute]
291
+ expr: JSExpr
292
+
293
+ def __init__(self, expr: JSExpr):
294
+ self.expr = expr
295
+
296
+ @override
297
+ def emit(self) -> str:
298
+ return f"...{self.expr.emit()}"
299
+
300
+
301
+ class JSProp(JSExpr):
302
+ __slots__ = ("key", "value") # pyright: ignore[reportUnannotatedClassAttribute]
303
+ key: JSString
304
+ value: JSExpr
305
+
306
+ def __init__(self, key: JSString, value: JSExpr):
307
+ self.key = key
308
+ self.value = value
309
+
310
+ @override
311
+ def emit(self) -> str:
312
+ return f"{self.key.emit()}: {self.value.emit()}"
313
+
314
+
315
+ class JSComputedProp(JSExpr):
316
+ __slots__ = ("key", "value") # pyright: ignore[reportUnannotatedClassAttribute]
317
+ key: JSExpr
318
+ value: JSExpr
319
+
320
+ def __init__(self, key: JSExpr, value: JSExpr):
321
+ self.key = key
322
+ self.value = value
323
+
324
+ @override
325
+ def emit(self) -> str:
326
+ return f"[{self.key.emit()}]: {self.value.emit()}"
327
+
328
+
329
+ class JSObjectExpr(JSExpr):
330
+ __slots__ = ("props",) # pyright: ignore[reportUnannotatedClassAttribute]
331
+ is_primary: ClassVar[bool] = True
332
+ props: Sequence[JSProp | JSComputedProp | JSSpread]
333
+
334
+ def __init__(self, props: Sequence[JSProp | JSComputedProp | JSSpread]):
335
+ self.props = props
336
+
337
+ @override
338
+ def emit(self) -> str:
339
+ inner = ", ".join(p.emit() for p in self.props)
340
+ return "{" + inner + "}"
341
+
342
+
343
+ class JSUnary(JSExpr):
344
+ __slots__ = ("op", "operand") # pyright: ignore[reportUnannotatedClassAttribute]
345
+ op: str # '-', '+', '!', 'typeof', 'await'
346
+ operand: JSExpr
347
+
348
+ def __init__(self, op: str, operand: JSExpr):
349
+ self.op = op
350
+ self.operand = operand
351
+
352
+ @override
353
+ def emit(self) -> str:
354
+ operand_code = _emit_child_for_binary_like(
355
+ self.operand, parent_op=self.op, side="unary"
356
+ )
357
+ if self.op == "typeof":
358
+ return f"typeof {operand_code}"
359
+ return f"{self.op}{operand_code}"
360
+
361
+
362
+ class JSAwait(JSExpr):
363
+ __slots__ = ("operand",) # pyright: ignore[reportUnannotatedClassAttribute]
364
+ operand: JSExpr
365
+
366
+ def __init__(self, operand: JSExpr):
367
+ self.operand = operand
368
+
369
+ @override
370
+ def emit(self) -> str:
371
+ operand_code = _emit_child_for_binary_like(
372
+ self.operand, parent_op="await", side="unary"
373
+ )
374
+ return f"await {operand_code}"
375
+
376
+
377
+ class JSBinary(JSExpr):
378
+ __slots__ = ("left", "op", "right") # pyright: ignore[reportUnannotatedClassAttribute]
379
+ left: JSExpr
380
+ op: str
381
+ right: JSExpr
382
+
383
+ def __init__(self, left: JSExpr, op: str, right: JSExpr):
384
+ self.left = left
385
+ self.op = op
386
+ self.right = right
387
+
388
+ @override
389
+ def emit(self) -> str:
390
+ # Left child
391
+ force_left_paren = False
392
+ # Special JS grammar rule: left operand of ** cannot be a unary +/- without parentheses
393
+ if (
394
+ self.op == "**"
395
+ and isinstance(self.left, JSUnary)
396
+ and self.left.op in {"-", "+"}
397
+ ):
398
+ force_left_paren = True
399
+ left_code = _emit_child_for_binary_like(
400
+ self.left,
401
+ parent_op=self.op,
402
+ side="left",
403
+ force_paren=force_left_paren,
404
+ )
405
+ # Right child
406
+ right_code = _emit_child_for_binary_like(
407
+ self.right, parent_op=self.op, side="right"
408
+ )
409
+ return f"{left_code} {self.op} {right_code}"
410
+
411
+
412
+ class JSLogicalChain(JSExpr):
413
+ __slots__ = ("op", "values") # pyright: ignore[reportUnannotatedClassAttribute]
414
+ op: str # '&&' or '||'
415
+ values: Sequence[JSExpr]
416
+
417
+ def __init__(self, op: str, values: Sequence[JSExpr]):
418
+ self.op = op
419
+ self.values = values
420
+
421
+ @override
422
+ def emit(self) -> str:
423
+ if len(self.values) == 1:
424
+ return self.values[0].emit()
425
+ parts: list[str] = []
426
+ for v in self.values:
427
+ # No strict left/right in chains, but treat as middle
428
+ code = _emit_child_for_binary_like(v, parent_op=self.op, side="chain")
429
+ parts.append(code)
430
+ return f" {self.op} ".join(parts)
431
+
432
+
433
+ class JSTertiary(JSExpr):
434
+ __slots__ = ("test", "if_true", "if_false") # pyright: ignore[reportUnannotatedClassAttribute]
435
+ test: JSExpr
436
+ if_true: JSExpr
437
+ if_false: JSExpr
438
+
439
+ def __init__(self, test: JSExpr, if_true: JSExpr, if_false: JSExpr):
440
+ self.test = test
441
+ self.if_true = if_true
442
+ self.if_false = if_false
443
+
444
+ @override
445
+ def emit(self) -> str:
446
+ return f"{self.test.emit()} ? {self.if_true.emit()} : {self.if_false.emit()}"
447
+
448
+
449
+ class JSFunctionDef(JSExpr):
450
+ __slots__ = ("params", "body", "name", "is_async") # pyright: ignore[reportUnannotatedClassAttribute]
451
+ params: Sequence[str]
452
+ body: Sequence[JSStmt]
453
+ name: str | None
454
+ is_async: bool
455
+
456
+ def __init__(
457
+ self,
458
+ params: Sequence[str],
459
+ body: Sequence[JSStmt],
460
+ name: str | None = None,
461
+ is_async: bool = False,
462
+ ):
463
+ self.params = params
464
+ self.body = body
465
+ self.name = name
466
+ self.is_async = is_async
467
+
468
+ @override
469
+ def emit(self) -> str:
470
+ params = ", ".join(self.params)
471
+ body_code = "\n".join(s.emit() for s in self.body)
472
+ prefix = "async " if self.is_async else ""
473
+ if self.name:
474
+ return f"{prefix}function {self.name}({params}){{\n{body_code}\n}}"
475
+ return f"{prefix}function({params}){{\n{body_code}\n}}"
476
+
477
+
478
+ class JSTemplate(JSExpr):
479
+ __slots__ = ("parts",) # pyright: ignore[reportUnannotatedClassAttribute]
480
+ is_primary: ClassVar[bool] = True
481
+ parts: Sequence[str | JSExpr]
482
+
483
+ def __init__(self, parts: Sequence[str | JSExpr]):
484
+ # parts are either raw strings (literal text) or JSExpr instances which are
485
+ # emitted inside ${...}
486
+ self.parts = parts
487
+
488
+ @override
489
+ def emit(self) -> str:
490
+ out: list[str] = ["`"]
491
+ for p in self.parts:
492
+ if isinstance(p, str):
493
+ out.append(
494
+ p.replace("\\", "\\\\")
495
+ .replace("`", "\\`")
496
+ .replace("${", "\\${")
497
+ .replace("\n", "\\n")
498
+ .replace("\r", "\\r")
499
+ .replace("\t", "\\t")
500
+ .replace("\b", "\\b")
501
+ .replace("\f", "\\f")
502
+ .replace("\v", "\\v")
503
+ .replace("\x00", "\\x00")
504
+ .replace("\u2028", "\\u2028")
505
+ .replace("\u2029", "\\u2029")
506
+ )
507
+ else:
508
+ out.append("${" + p.emit() + "}")
509
+ out.append("`")
510
+ return "".join(out)
511
+
512
+
513
+ class JSMember(JSExpr):
514
+ __slots__ = ("obj", "prop") # pyright: ignore[reportUnannotatedClassAttribute]
515
+ obj: JSExpr
516
+ prop: str
517
+
518
+ def __init__(self, obj: JSExpr, prop: str):
519
+ self.obj = obj
520
+ self.prop = prop
521
+
522
+ @override
523
+ def emit(self) -> str:
524
+ obj_code = _emit_child_for_primary(self.obj)
525
+ return f"{obj_code}.{self.prop}"
526
+
527
+ @override
528
+ def emit_call(self, args: list[Any], kwargs: dict[str, Any]) -> JSExpr:
529
+ """Called when this member is used as a function: obj.prop(args).
530
+
531
+ Checks for Python builtin method transpilation (e.g., str.upper -> toUpperCase),
532
+ then falls back to regular JSMemberCall.
533
+ """
534
+ if kwargs:
535
+ raise JSCompilationError("Keyword arguments not supported in method call")
536
+ # Convert args to JSExpr
537
+ js_args = [JSExpr.of(a) for a in args]
538
+ # Check for Python builtin method transpilation (late import to avoid cycle)
539
+ from pulse.transpiler.builtins import emit_method
540
+
541
+ result = emit_method(self.obj, self.prop, js_args)
542
+ if result is not None:
543
+ return result
544
+ return JSMemberCall(self.obj, self.prop, js_args)
545
+
546
+
547
+ class JSSubscript(JSExpr):
548
+ __slots__ = ("obj", "index") # pyright: ignore[reportUnannotatedClassAttribute]
549
+ obj: JSExpr
550
+ index: JSExpr
551
+
552
+ def __init__(self, obj: JSExpr, index: JSExpr):
553
+ self.obj = obj
554
+ self.index = index
555
+
556
+ @override
557
+ def emit(self) -> str:
558
+ obj_code = _emit_child_for_primary(self.obj)
559
+ return f"{obj_code}[{self.index.emit()}]"
560
+
561
+
562
+ class JSCall(JSExpr):
563
+ __slots__ = ("callee", "args") # pyright: ignore[reportUnannotatedClassAttribute]
564
+ callee: JSExpr # typically JSIdentifier
565
+ args: Sequence[JSExpr]
566
+
567
+ def __init__(self, callee: JSExpr, args: Sequence[JSExpr]):
568
+ self.callee = callee
569
+ self.args = args
570
+
571
+ @override
572
+ def emit(self) -> str:
573
+ fn = _emit_child_for_primary(self.callee)
574
+ return f"{fn}({', '.join(a.emit() for a in self.args)})"
575
+
576
+
577
+ class JSMemberCall(JSExpr):
578
+ __slots__ = ("obj", "method", "args") # pyright: ignore[reportUnannotatedClassAttribute]
579
+ obj: JSExpr
580
+ method: str
581
+ args: Sequence[JSExpr]
582
+
583
+ def __init__(self, obj: JSExpr, method: str, args: Sequence[JSExpr]):
584
+ self.obj = obj
585
+ self.method = method
586
+ self.args = args
587
+
588
+ @override
589
+ def emit(self) -> str:
590
+ obj_code = _emit_child_for_primary(self.obj)
591
+ return f"{obj_code}.{self.method}({', '.join(a.emit() for a in self.args)})"
592
+
593
+
594
+ class JSNew(JSExpr):
595
+ __slots__ = ("ctor", "args") # pyright: ignore[reportUnannotatedClassAttribute]
596
+ ctor: JSExpr
597
+ args: Sequence[JSExpr]
598
+
599
+ def __init__(self, ctor: JSExpr, args: Sequence[JSExpr]):
600
+ self.ctor = ctor
601
+ self.args = args
602
+
603
+ @override
604
+ def emit(self) -> str:
605
+ ctor_code = _emit_child_for_primary(self.ctor)
606
+ return f"new {ctor_code}({', '.join(a.emit() for a in self.args)})"
607
+
608
+
609
+ class JSArrowFunction(JSExpr):
610
+ __slots__ = ("params_code", "body") # pyright: ignore[reportUnannotatedClassAttribute]
611
+ params_code: str # already formatted e.g. 'x' or '(a, b)' or '([k, v])'
612
+ body: JSExpr | JSBlock
613
+
614
+ def __init__(self, params_code: str, body: JSExpr | JSBlock):
615
+ self.params_code = params_code
616
+ self.body = body
617
+
618
+ @override
619
+ def emit(self) -> str:
620
+ return f"{self.params_code} => {self.body.emit()}"
621
+
622
+
623
+ class JSComma(JSExpr):
624
+ __slots__ = ("values",) # pyright: ignore[reportUnannotatedClassAttribute]
625
+ values: Sequence[JSExpr]
626
+
627
+ def __init__(self, values: Sequence[JSExpr]):
628
+ self.values = values
629
+
630
+ @override
631
+ def emit(self) -> str:
632
+ # Always wrap comma expressions in parentheses to avoid precedence surprises
633
+ inner = ", ".join(v.emit() for v in self.values)
634
+ return f"({inner})"
635
+
636
+
637
+ class JSTransformer(JSExpr):
638
+ """JSExpr that wraps a function transforming JSExpr args to JSExpr output.
639
+
640
+ Generalizes the pattern of call-only expressions. The wrapped function
641
+ receives positional and keyword JSExpr arguments and returns a JSExpr.
642
+
643
+ Example:
644
+ emit_len = JSTransformer(lambda x: JSMember(x, "length"), name="len")
645
+ # When called: emit_len.emit_call([some_expr], {}) -> JSMember(some_expr, "length")
646
+ """
647
+
648
+ __slots__ = ("fn", "name") # pyright: ignore[reportUnannotatedClassAttribute]
649
+ fn: Callable[..., JSExpr]
650
+ name: str # Optional name for error messages
651
+
652
+ def __init__(self, fn: Callable[..., JSExpr], name: str = ""):
653
+ self.fn = fn
654
+ self.name = name
655
+
656
+ @override
657
+ def emit(self) -> str:
658
+ label = self.name or "JSTransformer"
659
+ raise JSCompilationError(f"{label} cannot be emitted directly - must be called")
660
+
661
+ @override
662
+ def emit_call(self, args: list[Any], kwargs: dict[str, Any]) -> JSExpr:
663
+ # Pass raw args to the transformer function - it decides what to convert
664
+ if kwargs:
665
+ return self.fn(*args, **kwargs)
666
+ return self.fn(*args)
667
+
668
+ @override
669
+ def emit_subscript(self, indices: list[Any]) -> JSExpr:
670
+ label = self.name or "JSTransformer"
671
+ raise JSCompilationError(f"{label} cannot be subscripted")
672
+
673
+ @override
674
+ def emit_getattr(self, attr: str) -> JSExpr:
675
+ label = self.name or "JSTransformer"
676
+ raise JSCompilationError(f"{label} cannot have attributes")
677
+
678
+
679
+ _F = TypeVar("_F", bound=Callable[..., Any])
680
+
681
+
682
+ @overload
683
+ def js_transformer(arg: str) -> Callable[[_F], _F]: ...
684
+
685
+
686
+ @overload
687
+ def js_transformer(arg: _F) -> _F: ...
688
+
689
+
690
+ def js_transformer(arg: str | _F) -> Callable[[_F], _F] | _F:
691
+ """Decorator/helper for JSTransformer.
692
+
693
+ Usage:
694
+ @js_transformer("len")
695
+ def emit_len(x): ...
696
+ or:
697
+ emit_len = js_transformer(lambda x: ...)
698
+
699
+ Returns a JSTransformer, but the type signature lies and preserves
700
+ the original function type. This allows decorated functions to have
701
+ proper return types (e.g., NoReturn for throw).
702
+ """
703
+ if isinstance(arg, str):
704
+
705
+ def decorator(fn: _F) -> _F:
706
+ return cast(_F, JSTransformer(fn, name=arg))
707
+
708
+ return decorator
709
+ elif callable(arg):
710
+ return cast(_F, JSTransformer(arg))
711
+ else:
712
+ raise TypeError(
713
+ "js_transformer expects a function or string (for decorator usage)"
714
+ )
715
+
716
+
717
+ class JSReturn(JSStmt):
718
+ __slots__ = ("value",) # pyright: ignore[reportUnannotatedClassAttribute]
719
+ value: JSExpr
720
+
721
+ def __init__(self, value: JSExpr):
722
+ self.value = value
723
+
724
+ @override
725
+ def emit(self) -> str:
726
+ return f"return {self.value.emit()};"
727
+
728
+
729
+ class JSThrow(JSStmt):
730
+ __slots__ = ("value",) # pyright: ignore[reportUnannotatedClassAttribute]
731
+ value: JSExpr
732
+
733
+ def __init__(self, value: JSExpr):
734
+ self.value = value
735
+
736
+ @override
737
+ def emit(self) -> str:
738
+ return f"throw {self.value.emit()};"
739
+
740
+
741
+ class JSStmtExpr(JSExpr):
742
+ """Expression wrapper for a statement (e.g., throw).
743
+
744
+ Used for constructs like `throw(x)` that syntactically look like function calls
745
+ but must be emitted as statements. When used as an expression-statement,
746
+ the transpiler unwraps this and emits the inner statement directly.
747
+ """
748
+
749
+ __slots__ = ("stmt", "name") # pyright: ignore[reportUnannotatedClassAttribute]
750
+ stmt: JSStmt
751
+ name: str # For error messages (e.g., "throw")
752
+
753
+ def __init__(self, stmt: JSStmt, name: str = ""):
754
+ self.stmt = stmt
755
+ self.name = name
756
+
757
+ @override
758
+ def emit(self) -> str:
759
+ label = self.name or "statement"
760
+ raise JSCompilationError(
761
+ f"'{label}' cannot be used inside an expression. "
762
+ + "Use it as a standalone statement instead. "
763
+ + f"For example, write `{label}(x)` on its own line, not `y = {label}(x)` or `f({label}(x))`."
764
+ )
765
+
766
+
767
+ class JSAssign(JSStmt):
768
+ __slots__ = ("name", "value", "declare") # pyright: ignore[reportUnannotatedClassAttribute]
769
+ name: str
770
+ value: JSExpr
771
+ declare: bool # when True emit 'let name = ...'
772
+
773
+ def __init__(self, name: str, value: JSExpr, declare: bool = False):
774
+ self.name = name
775
+ self.value = value
776
+ self.declare = declare
777
+
778
+ @override
779
+ def emit(self) -> str:
780
+ if self.declare:
781
+ return f"let {self.name} = {self.value.emit()};"
782
+ return f"{self.name} = {self.value.emit()};"
783
+
784
+
785
+ class JSRaw(JSExpr):
786
+ __slots__ = ("content",) # pyright: ignore[reportUnannotatedClassAttribute]
787
+ is_primary: ClassVar[bool] = True
788
+ content: str
789
+
790
+ def __init__(self, content: str):
791
+ self.content = content
792
+
793
+ @override
794
+ def emit(self) -> str:
795
+ return self.content
796
+
797
+
798
+ ###############################################################################
799
+ # JSX AST (minimal)
800
+ ###############################################################################
801
+
802
+
803
+ def _check_not_interpreted_mode(node_type: str) -> None:
804
+ """Raise an error if we're in interpreted mode - JSX can't be eval'd."""
805
+ if is_interpreted_mode():
806
+ raise ValueError(
807
+ f"{node_type} cannot be used in interpreted mode (as a prop or child value). "
808
+ + "JSX syntax requires transpilation and cannot be evaluated at runtime. "
809
+ + "Use standard VDOM elements (ps.div, ps.span, etc.) instead."
810
+ )
811
+
812
+
813
+ def _escape_jsx_text(text: str) -> str:
814
+ # Minimal escaping for text nodes
815
+ return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
816
+
817
+
818
+ class JSXProp(JSExpr):
819
+ __slots__ = ("name", "value") # pyright: ignore[reportUnannotatedClassAttribute]
820
+ name: str
821
+ value: JSExpr | None
822
+
823
+ def __init__(self, name: str, value: JSExpr | None = None):
824
+ self.name = name
825
+ self.value = value
826
+
827
+ @override
828
+ def emit(self) -> str:
829
+ _check_not_interpreted_mode("JSXProp")
830
+ if self.value is None:
831
+ return self.name
832
+ # Prefer compact string literal attribute when possible
833
+ if isinstance(self.value, JSString):
834
+ return f"{self.name}={self.value.emit()}"
835
+ return self.name + "={" + self.value.emit() + "}"
836
+
837
+
838
+ class JSXSpreadProp(JSExpr):
839
+ __slots__ = ("value",) # pyright: ignore[reportUnannotatedClassAttribute]
840
+ value: JSExpr
841
+
842
+ def __init__(self, value: JSExpr):
843
+ self.value = value
844
+
845
+ @override
846
+ def emit(self) -> str:
847
+ _check_not_interpreted_mode("JSXSpreadProp")
848
+ return f"{{...{self.value.emit()}}}"
849
+
850
+
851
+ class JSXElement(JSExpr):
852
+ __slots__ = ("tag", "props", "children") # pyright: ignore[reportUnannotatedClassAttribute]
853
+ is_jsx: ClassVar[bool] = True
854
+ is_primary: ClassVar[bool] = True
855
+ tag: str | JSExpr
856
+ props: Sequence[JSXProp | JSXSpreadProp]
857
+ children: Sequence[str | JSExpr | JSXElement]
858
+
859
+ def __init__(
860
+ self,
861
+ tag: str | JSExpr,
862
+ props: Sequence[JSXProp | JSXSpreadProp] = (),
863
+ children: Sequence[str | JSExpr | JSXElement] = (),
864
+ ):
865
+ self.tag = tag
866
+ self.props = props
867
+ self.children = children
868
+
869
+ @override
870
+ def emit(self) -> str:
871
+ _check_not_interpreted_mode("JSXElement")
872
+ tag_code = self.tag if isinstance(self.tag, str) else self.tag.emit()
873
+ props_code = " ".join(p.emit() for p in self.props) if self.props else ""
874
+ if not self.children:
875
+ if props_code:
876
+ return f"<{tag_code} {props_code} />"
877
+ return f"<{tag_code} />"
878
+ # Open tag
879
+ open_tag = f"<{tag_code}>" if not props_code else f"<{tag_code} {props_code}>"
880
+ # Children
881
+ child_parts: list[str] = []
882
+ for c in self.children:
883
+ if isinstance(c, str):
884
+ child_parts.append(_escape_jsx_text(c))
885
+ elif isinstance(c, JSXElement) or (isinstance(c, JSExpr) and c.is_jsx):
886
+ child_parts.append(c.emit())
887
+ else:
888
+ child_parts.append("{" + c.emit() + "}")
889
+ inner = "".join(child_parts)
890
+ return f"{open_tag}{inner}</{tag_code}>"
891
+
892
+
893
+ class JSXFragment(JSExpr):
894
+ __slots__ = ("children",) # pyright: ignore[reportUnannotatedClassAttribute]
895
+ is_jsx: ClassVar[bool] = True
896
+ is_primary: ClassVar[bool] = True
897
+ children: Sequence[str | JSExpr | JSXElement]
898
+
899
+ def __init__(self, children: Sequence[str | JSExpr | JSXElement] = ()):
900
+ self.children = children
901
+
902
+ @override
903
+ def emit(self) -> str:
904
+ _check_not_interpreted_mode("JSXFragment")
905
+ if not self.children:
906
+ return "<></>"
907
+ parts: list[str] = []
908
+ for c in self.children:
909
+ if isinstance(c, str):
910
+ parts.append(_escape_jsx_text(c))
911
+ elif isinstance(c, JSXElement) or (isinstance(c, JSExpr) and c.is_jsx):
912
+ parts.append(c.emit())
913
+ else:
914
+ parts.append("{" + c.emit() + "}")
915
+ return "<>" + "".join(parts) + "</>"
916
+
917
+
918
+ class JSImport:
919
+ __slots__ = ("src", "default", "named") # pyright: ignore[reportUnannotatedClassAttribute]
920
+ src: str
921
+ default: str | None
922
+ named: list[str | tuple[str, str]]
923
+
924
+ def __init__(
925
+ self,
926
+ src: str,
927
+ default: str | None = None,
928
+ named: list[str | tuple[str, str]] | None = None,
929
+ ):
930
+ self.src = src
931
+ self.default = default
932
+ self.named = named if named is not None else []
933
+
934
+ def emit(self) -> str:
935
+ parts: list[str] = []
936
+ if self.default:
937
+ parts.append(self.default)
938
+ if self.named:
939
+ named_parts: list[str] = []
940
+ for n in self.named:
941
+ if isinstance(n, tuple):
942
+ named_parts.append(f"{n[0]} as {n[1]}")
943
+ else:
944
+ named_parts.append(n)
945
+ if named_parts:
946
+ if self.default:
947
+ parts.append(",")
948
+ parts.append("{" + ", ".join(named_parts) + "}")
949
+ return f"import {' '.join(parts)} from {JSString(self.src).emit()};"
950
+
951
+
952
+ # -----------------------------
953
+ # Precedence helpers
954
+ # -----------------------------
955
+
956
+ PRIMARY_PRECEDENCE = 20
957
+
958
+
959
+ def op_precedence(op: str) -> int:
960
+ # Higher number = binds tighter
961
+ if op in {".", "[]", "()"}: # pseudo ops for primary contexts
962
+ return PRIMARY_PRECEDENCE
963
+ if op in {"!", "+u", "-u"}: # unary; we encode + and - as unary with +u/-u
964
+ return 17
965
+ if op in {"typeof", "await"}:
966
+ return 17
967
+ if op == "**":
968
+ return 16
969
+ if op in {"*", "/", "%"}:
970
+ return 15
971
+ if op in {"+", "-"}:
972
+ return 14
973
+ if op in {"<", "<=", ">", ">=", "===", "!=="}:
974
+ return 12
975
+ if op == "instanceof":
976
+ return 12
977
+ if op == "in":
978
+ return 12
979
+ if op == "&&":
980
+ return 7
981
+ if op == "||":
982
+ return 6
983
+ if op == "??":
984
+ return 6
985
+ if op == "?:": # ternary
986
+ return 4
987
+ if op == ",":
988
+ return 1
989
+ return 0
990
+
991
+
992
+ def op_is_right_associative(op: str) -> bool:
993
+ return op == "**"
994
+
995
+
996
+ def expr_precedence(e: JSExpr) -> int:
997
+ if isinstance(e, JSBinary):
998
+ return op_precedence(e.op)
999
+ if isinstance(e, JSUnary):
1000
+ # Distinguish unary + and - from binary precedence table by tag
1001
+ tag = "+u" if e.op == "+" else ("-u" if e.op == "-" else e.op)
1002
+ return op_precedence(tag)
1003
+ if isinstance(e, JSAwait):
1004
+ return op_precedence("await")
1005
+ if isinstance(e, JSTertiary):
1006
+ return op_precedence("?:")
1007
+ if isinstance(e, JSLogicalChain):
1008
+ return op_precedence(e.op)
1009
+ if isinstance(e, JSComma):
1010
+ return op_precedence(",")
1011
+ # Nullish now represented as JSBinary with op "??"; precedence resolved below
1012
+ if isinstance(e, (JSMember, JSSubscript, JSCall, JSMemberCall, JSNew)):
1013
+ return op_precedence(".")
1014
+ # Primary expressions (identifiers, literals, containers) don't need parens
1015
+ if e.is_primary:
1016
+ return PRIMARY_PRECEDENCE
1017
+ return 0
1018
+
1019
+
1020
+ class JSBlock(JSStmt):
1021
+ __slots__ = ("body",) # pyright: ignore[reportUnannotatedClassAttribute]
1022
+ body: Sequence[JSStmt]
1023
+
1024
+ def __init__(self, body: Sequence[JSStmt]):
1025
+ self.body = body
1026
+
1027
+ @override
1028
+ def emit(self) -> str:
1029
+ body_code = "\n".join(s.emit() for s in self.body)
1030
+ return f"{{\n{body_code}\n}}"
1031
+
1032
+
1033
+ class JSAugAssign(JSStmt):
1034
+ __slots__ = ("name", "op", "value") # pyright: ignore[reportUnannotatedClassAttribute]
1035
+ name: str
1036
+ op: str
1037
+ value: JSExpr
1038
+
1039
+ def __init__(self, name: str, op: str, value: JSExpr):
1040
+ self.name = name
1041
+ self.op = op
1042
+ self.value = value
1043
+
1044
+ @override
1045
+ def emit(self) -> str:
1046
+ return f"{self.name} {self.op}= {self.value.emit()};"
1047
+
1048
+
1049
+ class JSConstAssign(JSStmt):
1050
+ __slots__ = ("name", "value") # pyright: ignore[reportUnannotatedClassAttribute]
1051
+ name: str
1052
+ value: JSExpr
1053
+
1054
+ def __init__(self, name: str, value: JSExpr):
1055
+ self.name = name
1056
+ self.value = value
1057
+
1058
+ @override
1059
+ def emit(self) -> str:
1060
+ return f"const {self.name} = {self.value.emit()};"
1061
+
1062
+
1063
+ class JSSingleStmt(JSStmt):
1064
+ __slots__ = ("expr",) # pyright: ignore[reportUnannotatedClassAttribute]
1065
+ expr: JSExpr
1066
+
1067
+ def __init__(self, expr: JSExpr):
1068
+ self.expr = expr
1069
+
1070
+ @override
1071
+ def emit(self) -> str:
1072
+ return f"{self.expr.emit()};"
1073
+
1074
+
1075
+ class JSMultiStmt(JSStmt):
1076
+ __slots__ = ("stmts",) # pyright: ignore[reportUnannotatedClassAttribute]
1077
+ stmts: Sequence[JSStmt]
1078
+
1079
+ def __init__(self, stmts: Sequence[JSStmt]):
1080
+ self.stmts = stmts
1081
+
1082
+ @override
1083
+ def emit(self) -> str:
1084
+ return "\n".join(s.emit() for s in self.stmts)
1085
+
1086
+
1087
+ class JSIf(JSStmt):
1088
+ __slots__ = ("test", "body", "orelse") # pyright: ignore[reportUnannotatedClassAttribute]
1089
+ test: JSExpr
1090
+ body: Sequence[JSStmt]
1091
+ orelse: Sequence[JSStmt]
1092
+
1093
+ def __init__(
1094
+ self, test: JSExpr, body: Sequence[JSStmt], orelse: Sequence[JSStmt] = ()
1095
+ ):
1096
+ self.test = test
1097
+ self.body = body
1098
+ self.orelse = orelse
1099
+
1100
+ @override
1101
+ def emit(self) -> str:
1102
+ body_code = "\n".join(s.emit() for s in self.body)
1103
+ if not self.orelse:
1104
+ return f"if ({self.test.emit()}){{\n{body_code}\n}}"
1105
+ else_code = "\n".join(s.emit() for s in self.orelse)
1106
+ return f"if ({self.test.emit()}){{\n{body_code}\n}} else {{\n{else_code}\n}}"
1107
+
1108
+
1109
+ class JSForOf(JSStmt):
1110
+ __slots__ = ("target", "iter_expr", "body") # pyright: ignore[reportUnannotatedClassAttribute]
1111
+ target: str | list[str]
1112
+ iter_expr: JSExpr
1113
+ body: Sequence[JSStmt]
1114
+
1115
+ def __init__(
1116
+ self, target: str | list[str], iter_expr: JSExpr, body: Sequence[JSStmt]
1117
+ ):
1118
+ self.target = target
1119
+ self.iter_expr = iter_expr
1120
+ self.body = body
1121
+
1122
+ @override
1123
+ def emit(self) -> str:
1124
+ body_code = "\n".join(s.emit() for s in self.body)
1125
+ target = self.target
1126
+ if not isinstance(target, str):
1127
+ target = f"[{', '.join(x for x in target)}]"
1128
+ return f"for (const {target} of {self.iter_expr.emit()}){{\n{body_code}\n}}"
1129
+
1130
+
1131
+ class JSWhile(JSStmt):
1132
+ __slots__ = ("test", "body") # pyright: ignore[reportUnannotatedClassAttribute]
1133
+ test: JSExpr
1134
+ body: Sequence[JSStmt]
1135
+
1136
+ def __init__(self, test: JSExpr, body: Sequence[JSStmt]):
1137
+ self.test = test
1138
+ self.body = body
1139
+
1140
+ @override
1141
+ def emit(self) -> str:
1142
+ body_code = "\n".join(s.emit() for s in self.body)
1143
+ return f"while ({self.test.emit()}){{\n{body_code}\n}}"
1144
+
1145
+
1146
+ class JSBreak(JSStmt):
1147
+ __slots__ = () # pyright: ignore[reportUnannotatedClassAttribute]
1148
+
1149
+ @override
1150
+ def emit(self) -> str:
1151
+ return "break;"
1152
+
1153
+
1154
+ class JSContinue(JSStmt):
1155
+ __slots__ = () # pyright: ignore[reportUnannotatedClassAttribute]
1156
+
1157
+ @override
1158
+ def emit(self) -> str:
1159
+ return "continue;"
1160
+
1161
+
1162
+ def _mixes_nullish_and_logical(parent_op: str, child: JSExpr) -> bool:
1163
+ if parent_op in {"&&", "||"} and isinstance(child, JSBinary) and child.op == "??":
1164
+ return True
1165
+ if parent_op == "??" and isinstance(child, JSLogicalChain):
1166
+ return True
1167
+ return False
1168
+
1169
+
1170
+ def _emit_child_for_binary_like(
1171
+ child: JSExpr, parent_op: str, side: str, force_paren: bool = False
1172
+ ) -> str:
1173
+ # side is one of: 'left', 'right', 'unary', 'chain'
1174
+ code = child.emit()
1175
+ if force_paren:
1176
+ return f"({code})"
1177
+ # Ternary as child should always be wrapped under binary-like contexts
1178
+ if isinstance(child, JSTertiary):
1179
+ return f"({code})"
1180
+ # Explicit parens when mixing ?? with &&/||
1181
+ if _mixes_nullish_and_logical(parent_op, child):
1182
+ return f"({code})"
1183
+ child_prec = expr_precedence(child)
1184
+ parent_prec = op_precedence(parent_op)
1185
+ if child_prec < parent_prec:
1186
+ return f"({code})"
1187
+ if child_prec == parent_prec:
1188
+ # Handle associativity for exact same precedence buckets
1189
+ if isinstance(child, JSBinary):
1190
+ if op_is_right_associative(parent_op):
1191
+ # Need parens on left child for same prec to preserve grouping
1192
+ if side == "left":
1193
+ return f"({code})"
1194
+ else:
1195
+ # Left-associative: protect right child when equal precedence
1196
+ if side == "right":
1197
+ return f"({code})"
1198
+ if isinstance(child, JSLogicalChain):
1199
+ # Same op chains don't need parens; different logical ops rely on precedence
1200
+ if child.op != parent_op:
1201
+ # '&&' has higher precedence than '||'; no parens needed for tighter child
1202
+ # But if equal (shouldn't happen here), remain as-is
1203
+ pass
1204
+ # For other equal-precedence non-binary nodes, keep as-is
1205
+ return code
1206
+
1207
+
1208
+ def _emit_child_for_primary(expr: JSExpr) -> str:
1209
+ code = expr.emit()
1210
+ if expr_precedence(expr) < PRIMARY_PRECEDENCE or isinstance(expr, JSTertiary):
1211
+ return f"({code})"
1212
+ return code
1213
+
1214
+
1215
+ def is_primary(expr: JSExpr):
1216
+ return isinstance(expr, (JSNumber, JSString, JSUndefined, JSNull, JSIdentifier))