typescratch 1.0.0__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.
typescratch/codegen.py ADDED
@@ -0,0 +1,1631 @@
1
+ """Code generator: AST -> Scratch 3.0 project.json.
2
+
3
+ Strategy
4
+ --------
5
+ Each TypeScratch sprite becomes a Scratch target. Each script becomes
6
+ a hat block + a chain of stack blocks. Substacks (if/repeat/forever)
7
+ become SUBSTACK inputs pointing at the first block of the inner chain.
8
+
9
+ Variables are resolved in two passes:
10
+ 1. Pre-scan the target for every `Name = ...` and `list Name` to
11
+ collect declared variables/lists.
12
+ 2. During codegen, a BareRef node resolves to a VarRef if its name
13
+ is in the declared set, otherwise to a StringLit (since "Scratch
14
+ has no strings, only text").
15
+
16
+ Inputs use the canonical Scratch 3.0 format:
17
+ * literal shadow : [1, [primitive_type, value]]
18
+ * block + shadow : [2, block_id, [primitive_type, ""]] (or null shadow)
19
+ * variable input : [3, [12, name, id], [12, name, id]]
20
+ * substack : [2, block_id]
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import hashlib
26
+ import os
27
+ import uuid
28
+ from typing import Any, Dict, List, Optional, Tuple
29
+
30
+ from . import ast_nodes as A
31
+ from . import blocks as B
32
+ from .errors import CompileError, Debug, did_you_mean
33
+ from . import assets as ASSETS
34
+
35
+
36
+ def gen_id() -> str:
37
+ return uuid.uuid4().hex[:20]
38
+
39
+
40
+ # Primitive shadow type codes
41
+ SHADOW_NUMBER = 4
42
+ SHADOW_COLOR = 9
43
+ SHADOW_STRING = 10
44
+ SHADOW_BROADCAST = 11
45
+ SHADOW_VAR = 12
46
+ SHADOW_LIST = 13
47
+
48
+
49
+ class Codegen:
50
+ def __init__(self, program: A.Program, debug: Optional[Debug] = None,
51
+ asset_root: Optional[str] = None):
52
+ self.program = program
53
+ self.debug = debug or Debug(enabled=False)
54
+ self.asset_root = asset_root # base dir for resolving relative img paths
55
+ # md5ext -> bytes (collected for SB3 packaging)
56
+ self.assets: Dict[str, bytes] = {}
57
+ # per-target state
58
+ self.current_target: Optional[A.Target] = None
59
+ self.current_blocks: Dict[str, dict] = {}
60
+ self.current_vars: Dict[str, str] = {} # name -> var_id
61
+ self.current_var_values: Dict[str, Any] = {} # name -> initial value
62
+ self.current_lists: Dict[str, str] = {} # name -> list_id
63
+ self.current_list_values: Dict[str, list] = {} # name -> initial list
64
+ self.current_broadcasts: Dict[str, str] = {} # msg -> msg (id = name)
65
+ self.current_custom_blocks: Dict[str, dict] = {} # name -> {proccode, argument_ids, warp}
66
+ # Per-custom-block state (set when generating a def body)
67
+ self.current_proc_params: Dict[str, str] = {} # param name -> argument_id
68
+
69
+ # =====================================================================
70
+ # Top-level
71
+ # =====================================================================
72
+ def generate(self) -> Tuple[dict, Dict[str, bytes]]:
73
+ self.debug.section("Codegen")
74
+ targets_json: List[dict] = []
75
+
76
+ stage = next((t for t in self.program.targets if t.kind == "stage"), None)
77
+ if stage is None:
78
+ stage = A.Target(kind="stage", name="Stage")
79
+ self.program.targets.insert(0, stage)
80
+
81
+ # Stage first
82
+ stage_json = self.gen_target(stage, is_stage=True, layer_order=0)
83
+ targets_json.append(stage_json)
84
+
85
+ # Then sprites
86
+ layer = 1
87
+ for tgt in self.program.targets:
88
+ if tgt.kind == "sprite":
89
+ targets_json.append(self.gen_target(tgt, is_stage=False, layer_order=layer))
90
+ layer += 1
91
+
92
+ # Collect ALL broadcasts from ALL sprites and merge them into the stage.
93
+ # In Scratch 3, broadcasts are stored on the stage target.
94
+ all_broadcasts = {}
95
+ for tgt_json in targets_json:
96
+ for bid, bname in tgt_json.get("broadcasts", {}).items():
97
+ all_broadcasts[bid] = bname
98
+ stage_json["broadcasts"] = all_broadcasts
99
+ # Clear broadcasts from sprites (they belong on the stage)
100
+ for tgt_json in targets_json:
101
+ if not tgt_json["isStage"]:
102
+ tgt_json["broadcasts"] = {}
103
+
104
+ extensions = self._convert_extensions()
105
+
106
+ project = {
107
+ "targets": targets_json,
108
+ "monitors": [],
109
+ "extensions": extensions,
110
+ "meta": {
111
+ "semver": "3.0.0",
112
+ "vm": "0.2.0",
113
+ "agent": "TypeScratch/1.0"
114
+ }
115
+ }
116
+ self.debug.dump("project.json", project)
117
+ return project, self.assets
118
+
119
+ def _convert_extensions(self) -> List[str]:
120
+ """Map TypeScratch extension names to Scratch extension IDs."""
121
+ ext_map = {
122
+ "pen": "pen",
123
+ "music": "music",
124
+ "video sensing": "video_sensing",
125
+ "videosensing": "video_sensing",
126
+ "text to speech": "text2speech",
127
+ "text2speech": "text2speech",
128
+ "tts": "text2speech",
129
+ "speech to text": "speech2text",
130
+ "speech2text": "speech2text",
131
+ "listen": "speech2text",
132
+ "translate": "translate",
133
+ "makey makey": "makeymakey",
134
+ "makeymakey": "makeymakey",
135
+ "microbit": "microbit",
136
+ "micro bit": "microbit",
137
+ "micro:bit": "microbit",
138
+ "micro :bit": "microbit",
139
+ "micro : bit": "microbit",
140
+ "mesh": "mesh",
141
+ "ev3": "ev3",
142
+ "lego ev3": "ev3",
143
+ "lego mindstorms": "ev3",
144
+ "boost": "boost",
145
+ "lego boost": "boost",
146
+ "wedo": "wedov2",
147
+ "wedo2": "wedov2",
148
+ "wedov2": "wedov2",
149
+ "lego wedo": "wedov2",
150
+ "lego wedo 2": "wedov2",
151
+ "lego wedo2": "wedov2",
152
+ "gdxfor": "gdxfor",
153
+ "go direct force": "gdxfor",
154
+ "go direct": "gdxfor",
155
+ "force and acceleration": "gdxfor",
156
+ "nfc": "nfc",
157
+ }
158
+ out = []
159
+ for ext in self.program.extensions:
160
+ key = ext.lower().strip()
161
+ mapped = ext_map.get(key, key.replace(" ", "_"))
162
+ if mapped not in out:
163
+ out.append(mapped)
164
+ return out
165
+
166
+ # =====================================================================
167
+ # Target
168
+ # =====================================================================
169
+ def gen_target(self, target: A.Target, is_stage: bool, layer_order: int) -> dict:
170
+ self.debug.line(f"target: {target.name} (stage={is_stage})")
171
+ self.current_target = target
172
+ self.current_blocks = {}
173
+ self.current_vars = {}
174
+ self.current_var_values = {}
175
+ self.current_lists = {}
176
+ self.current_list_values = {}
177
+ self.current_broadcasts = {}
178
+ self.current_custom_blocks = {}
179
+ self.current_proc_params = {}
180
+
181
+ # Pre-scan for variables and lists
182
+ self._scan_variables(target)
183
+ self._scan_lists(target)
184
+
185
+ # Generate custom block definitions (procedures_definition)
186
+ for cb in target.custom_blocks:
187
+ self.gen_custom_block_def(cb)
188
+
189
+ # Generate scripts
190
+ for script in target.scripts:
191
+ self.gen_script(script)
192
+
193
+ # Costumes
194
+ costumes_json = self.gen_costumes(target, is_stage)
195
+
196
+ # Sounds
197
+ sounds_json = self.gen_sounds(target)
198
+
199
+ # Variables JSON - Scratch 3 format: {id: [name, value]}
200
+ variables_json = {}
201
+ for vname, vid in self.current_vars.items():
202
+ val = self.current_var_values.get(vname, 0)
203
+ variables_json[vid] = [vname, val]
204
+
205
+ # Lists JSON - Scratch 3 format: {id: [name, [items...]]}
206
+ lists_json = {}
207
+ for lname, lid in self.current_lists.items():
208
+ val = self.current_list_values.get(lname, [])
209
+ lists_json[lid] = [lname, val]
210
+
211
+ target_json: dict = {
212
+ "isStage": is_stage,
213
+ "name": target.name,
214
+ "variables": variables_json,
215
+ "lists": lists_json,
216
+ "broadcasts": self.current_broadcasts,
217
+ "blocks": self.current_blocks,
218
+ "comments": {},
219
+ "currentCostume": 0,
220
+ "costumes": costumes_json,
221
+ "sounds": sounds_json,
222
+ "volume": 100,
223
+ "layerOrder": layer_order,
224
+ }
225
+ if is_stage:
226
+ target_json.update({
227
+ "tempo": 60,
228
+ "videoState": "on",
229
+ "videoTransparency": 50,
230
+ "textToSpeechLanguage": None,
231
+ })
232
+ else:
233
+ target_json.update({
234
+ "visible": target.visible,
235
+ "x": target.x,
236
+ "y": target.y,
237
+ "size": target.size,
238
+ "direction": target.direction,
239
+ "draggable": False,
240
+ "rotationStyle": B.ROTATION_STYLES.get(target.rotation_style, "all around"),
241
+ })
242
+ return target_json
243
+
244
+ # ---- variable / list scanning -----------------------------------------
245
+ def _scan_variables(self, target: A.Target) -> None:
246
+ """Walk target.scripts and target.custom_blocks looking for
247
+ VarAssign / VarModify nodes and declare them."""
248
+ def walk(stmts):
249
+ for s in stmts:
250
+ if isinstance(s, A.VarAssign):
251
+ self._declare_var(s.name, s.value)
252
+ elif isinstance(s, A.VarModify):
253
+ self._declare_var(s.name, 0)
254
+ elif isinstance(s, A.IfStatement):
255
+ walk(s.then_body)
256
+ if s.else_body:
257
+ walk(s.else_body)
258
+ elif isinstance(s, (A.RepeatStatement, A.RepeatUntilStatement,
259
+ A.ForeverStatement, A.WhileStatement,
260
+ A.ForEachStatement, A.AllAtOnceStatement)):
261
+ walk(s.body)
262
+
263
+ # If the user wrote `Points = 0` somewhere, declare Points as a var
264
+ # with initial value 0 (only the FIRST assignment counts as init).
265
+ for script in target.scripts:
266
+ walk(script.body)
267
+ for cb in target.custom_blocks:
268
+ walk(cb.body)
269
+
270
+ def _declare_var(self, name: str, value: Any) -> None:
271
+ if name in self.current_vars:
272
+ return
273
+ vid = gen_id()
274
+ self.current_vars[name] = vid
275
+ # Only use the value if it's a literal. BareRef values get
276
+ # resolved: if it's a known variable or builtin reporter, the
277
+ # initial value is 0 (the actual value is set at runtime by the
278
+ # `set var to ...` block); otherwise it's a string literal.
279
+ if isinstance(value, A.NumberLit):
280
+ self.current_var_values[name] = value.value
281
+ elif isinstance(value, A.StringLit):
282
+ self.current_var_values[name] = value.value
283
+ elif isinstance(value, A.BareRef):
284
+ lower = value.name.lower()
285
+ if (value.name in self.current_vars
286
+ or lower in B.BUILTIN_ALIASES
287
+ or value.name in B.REPORTERS):
288
+ # will be set at runtime - initialize to 0
289
+ self.current_var_values[name] = 0
290
+ else:
291
+ # genuine string literal
292
+ self.current_var_values[name] = value.name
293
+ else:
294
+ # complex expression - will be set at runtime
295
+ self.current_var_values[name] = 0
296
+
297
+ def _scan_lists(self, target: A.Target) -> None:
298
+ decls = getattr(target, "_list_decls", [])
299
+ for decl in decls:
300
+ name = decl["name"]
301
+ if name in self.current_lists:
302
+ continue
303
+ lid = gen_id()
304
+ self.current_lists[name] = lid
305
+ # Resolve initial values
306
+ initial = []
307
+ for item in decl["initial"]:
308
+ if isinstance(item, A.NumberLit):
309
+ initial.append(item.value)
310
+ elif isinstance(item, A.StringLit):
311
+ initial.append(item.value)
312
+ elif isinstance(item, A.BareRef):
313
+ initial.append(item.name)
314
+ else:
315
+ initial.append(0)
316
+ self.current_list_values[name] = initial
317
+
318
+ # =====================================================================
319
+ # Costumes and sounds
320
+ # =====================================================================
321
+ def gen_costumes(self, target: A.Target, is_stage: bool) -> List[dict]:
322
+ costumes: List[dict] = []
323
+ if is_stage:
324
+ # Stage costumes = backdrops declared via b "name" ...
325
+ if not target.costumes:
326
+ # Default blank backdrop
327
+ name, data, _ignored_md5ext, rcx, rcy = ASSETS.default_backdrop()
328
+ asset_id = _md5(data)
329
+ md5ext = f"{asset_id}.svg"
330
+ self.assets[md5ext] = data
331
+ costumes.append({
332
+ "assetId": asset_id,
333
+ "name": name,
334
+ "bitmapResolution": 1,
335
+ "md5ext": md5ext,
336
+ "dataFormat": "svg",
337
+ "rotationCenterX": rcx,
338
+ "rotationCenterY": rcy,
339
+ })
340
+ for c in target.costumes:
341
+ name = c["name"]
342
+ path = c.get("path")
343
+ data, _ignored_md5ext, fmt, rcx, rcy = self._load_asset(path, is_stage=True)
344
+ asset_id = _md5(data)
345
+ md5ext = f"{asset_id}.{fmt}"
346
+ self.assets[md5ext] = data
347
+ costumes.append({
348
+ "assetId": asset_id,
349
+ "name": name,
350
+ "bitmapResolution": 1 if fmt == "svg" else 2,
351
+ "md5ext": md5ext,
352
+ "dataFormat": fmt,
353
+ "rotationCenterX": rcx,
354
+ "rotationCenterY": rcy,
355
+ })
356
+ else:
357
+ # Sprite costume
358
+ path = target.img
359
+ data, _ignored_md5ext, fmt, rcx, rcy = self._load_asset(path, is_stage=False)
360
+ asset_id = _md5(data)
361
+ md5ext = f"{asset_id}.{fmt}"
362
+ self.assets[md5ext] = data
363
+ costumes.append({
364
+ "assetId": asset_id,
365
+ "name": "costume1",
366
+ "bitmapResolution": 1 if fmt == "svg" else 2,
367
+ "md5ext": md5ext,
368
+ "dataFormat": fmt,
369
+ "rotationCenterX": rcx,
370
+ "rotationCenterY": rcy,
371
+ })
372
+ return costumes
373
+
374
+ def gen_sounds(self, target: A.Target) -> List[dict]:
375
+ # No sound declaration syntax yet - emit an empty list
376
+ return []
377
+
378
+ def _load_asset(self, path: Optional[str], is_stage: bool) -> Tuple[bytes, str, str, int, int]:
379
+ """Returns (data, md5ext, format, rotationCenterX, rotationCenterY)."""
380
+ if path:
381
+ # Resolve relative to asset_root if set
382
+ full = path
383
+ if self.asset_root and not os.path.isabs(path):
384
+ full = os.path.join(self.asset_root, path)
385
+ if os.path.isfile(full):
386
+ with open(full, "rb") as f:
387
+ data = f.read()
388
+ fmt = "svg" if full.lower().endswith(".svg") else (
389
+ "png" if full.lower().endswith(".png") else "svg"
390
+ )
391
+ # rotation center: half of image size (rough)
392
+ rcx, rcy = self._image_rotation_center(data, fmt, is_stage)
393
+ md5 = _md5(data)
394
+ return data, f"{md5}.{fmt}", fmt, rcx, rcy
395
+ # File not found - fall through to default
396
+ if is_stage:
397
+ name, data, md5ext, rcx, rcy = ASSETS.default_backdrop()
398
+ return data, md5ext, "svg", rcx, rcy
399
+ else:
400
+ name, data, md5ext, rcx, rcy = ASSETS.default_sprite_costume()
401
+ return data, md5ext, "svg", rcx, rcy
402
+
403
+ def _image_rotation_center(self, data: bytes, fmt: str, is_stage: bool) -> Tuple[int, int]:
404
+ """Best-effort rotation center based on image dimensions."""
405
+ if fmt == "svg":
406
+ # Try to parse viewBox
407
+ try:
408
+ txt = data.decode("utf-8", errors="replace")
409
+ import re
410
+ m = re.search(r'viewBox=["\']\s*[\d.\-]+\s+[\d.\-]+\s+([\d.]+)\s+([\d.]+)', txt)
411
+ if m:
412
+ w, h = float(m.group(1)), float(m.group(2))
413
+ return (int(w / 2), int(h / 2))
414
+ m = re.search(r'<svg[^>]*width=["\']([\d.]+)', txt)
415
+ m2 = re.search(r'<svg[^>]*height=["\']([\d.]+)', txt)
416
+ if m and m2:
417
+ w, h = float(m.group(1)), float(m2.group(1))
418
+ return (int(w / 2), int(h / 2))
419
+ except Exception:
420
+ pass
421
+ return (240, 180) if is_stage else (48, 50)
422
+ # PNG: read header for dimensions
423
+ try:
424
+ if data[:8] == b"\x89PNG\r\n\x1a\n":
425
+ w = int.from_bytes(data[16:20], "big")
426
+ h = int.from_bytes(data[20:24], "big")
427
+ return (w // 2, h // 2)
428
+ except Exception:
429
+ pass
430
+ return (240, 180) if is_stage else (48, 50)
431
+
432
+ # =====================================================================
433
+ # Script (hat + body)
434
+ # =====================================================================
435
+ def gen_script(self, script: A.Script) -> None:
436
+ hat = script.hat
437
+ spec = B.HAT_BLOCKS.get(hat.kind)
438
+ if spec is None:
439
+ raise CompileError(f"unknown hat kind: {hat.kind!r}", hat.line)
440
+
441
+ # Emit warning for hidden/experimental blocks
442
+ warn = spec.get("_warn")
443
+ if warn:
444
+ import sys
445
+ print(f"WARNING (line {hat.line}): {warn}", file=sys.stderr)
446
+
447
+ hat_id = gen_id()
448
+ hat_block: dict = {
449
+ "opcode": spec["opcode"],
450
+ "next": None,
451
+ "parent": None,
452
+ "inputs": {},
453
+ "fields": {},
454
+ "shadow": False,
455
+ "topLevel": True,
456
+ "x": 0,
457
+ "y": 0,
458
+ }
459
+
460
+ # Hat fields
461
+ for fname, arg_idx in spec.get("fields", []):
462
+ val = hat.args[arg_idx] if arg_idx < len(hat.args) else ""
463
+ # For broadcast receive hat, the field name is BROADCAST_OPTION
464
+ # and the value is [name, broadcast_id] where broadcast_id is a
465
+ # unique ID that must match the broadcasts dict.
466
+ if hat.kind == "receive":
467
+ # Generate a unique broadcast ID
468
+ bcast_id = gen_id()
469
+ hat_block["fields"]["BROADCAST_OPTION"] = [val, bcast_id]
470
+ # Track the broadcast with its ID
471
+ self.current_broadcasts[bcast_id] = val
472
+ else:
473
+ hat_block["fields"][fname] = [val, None]
474
+
475
+ # Hat inputs (for greater_than)
476
+ arg_consumed = 0
477
+ for input_name, input_kind, source in spec.get("args", []):
478
+ # for greater_than, the VALUE comes from args[1]
479
+ if hat.kind == "greater_than":
480
+ val_node = hat.args[1] if len(hat.args) > 1 else A.NumberLit(0)
481
+ else:
482
+ val_node = hat.args[arg_consumed] if arg_consumed < len(hat.args) else None
483
+ arg_consumed += 1
484
+ shadow, block_id = self.gen_input_value(val_node, input_kind)
485
+ hat_block["inputs"][input_name] = self._pack_input(input_kind, shadow, block_id)
486
+
487
+ self.current_blocks[hat_id] = hat_block
488
+
489
+ # Body
490
+ if script.body:
491
+ first_id, last_id = self.gen_chain(script.body, parent_id=hat_id)
492
+ hat_block["next"] = first_id
493
+
494
+ # =====================================================================
495
+ # Statement chain
496
+ # =====================================================================
497
+ def gen_chain(self, stmts: List[A.Statement], parent_id: Optional[str]) -> Tuple[Optional[str], Optional[str]]:
498
+ """Generate a chain of statements linked via parent/next.
499
+ Returns (first_id, last_id)."""
500
+ if not stmts:
501
+ return None, None
502
+ first_id: Optional[str] = None
503
+ prev_id: Optional[str] = parent_id
504
+ for stmt in stmts:
505
+ top_id, last_id = self.gen_statement(stmt, parent_id=prev_id)
506
+ if top_id is None:
507
+ continue
508
+ if first_id is None:
509
+ first_id = top_id
510
+ if prev_id is not None:
511
+ self.current_blocks[prev_id]["next"] = top_id
512
+ prev_id = last_id
513
+ return first_id, prev_id
514
+
515
+ # =====================================================================
516
+ # Statement dispatch
517
+ # =====================================================================
518
+ def gen_statement(self, stmt, parent_id: Optional[str]) -> Tuple[Optional[str], Optional[str]]:
519
+ if isinstance(stmt, A.VarAssign):
520
+ return self.gen_var_assign(stmt, parent_id)
521
+ if isinstance(stmt, A.VarModify):
522
+ return self.gen_var_modify(stmt, parent_id)
523
+ if isinstance(stmt, A.ListOp):
524
+ return self.gen_list_op(stmt, parent_id)
525
+ if isinstance(stmt, A.IfStatement):
526
+ return self.gen_if(stmt, parent_id)
527
+ if isinstance(stmt, A.RepeatStatement):
528
+ return self.gen_repeat(stmt, parent_id)
529
+ if isinstance(stmt, A.RepeatUntilStatement):
530
+ return self.gen_repeat_until(stmt, parent_id)
531
+ if isinstance(stmt, A.ForeverStatement):
532
+ return self.gen_forever(stmt, parent_id)
533
+ if isinstance(stmt, A.WhileStatement):
534
+ return self.gen_while(stmt, parent_id)
535
+ if isinstance(stmt, A.ForEachStatement):
536
+ return self.gen_for_each(stmt, parent_id)
537
+ if isinstance(stmt, A.AllAtOnceStatement):
538
+ return self.gen_all_at_once(stmt, parent_id)
539
+ if isinstance(stmt, A.WaitStatement):
540
+ return self.gen_simple_stack("control_wait",
541
+ [("DURATION", "number", stmt.secs)],
542
+ parent_id, stmt.line)
543
+ if isinstance(stmt, A.WaitUntilStatement):
544
+ return self.gen_wait_until(stmt, parent_id)
545
+ if isinstance(stmt, A.StopStatement):
546
+ return self.gen_stop(stmt, parent_id)
547
+ if isinstance(stmt, A.BroadcastStatement):
548
+ opcode = "event_broadcastandwait" if stmt.wait else "event_broadcast"
549
+ # message must be a broadcast input - if it's a literal string,
550
+ # we need to create a broadcast msg
551
+ msg = self._resolve_broadcast_msg(stmt.msg)
552
+ return self.gen_simple_stack(opcode,
553
+ [("BROADCAST_INPUT", "broadcast", msg)],
554
+ parent_id, stmt.line)
555
+ if isinstance(stmt, A.BlockCall):
556
+ return self.gen_block_call(stmt, parent_id)
557
+ raise CompileError(f"unknown statement type: {type(stmt).__name__}", stmt.line)
558
+
559
+ def gen_simple_stack(self, opcode: str, inputs_spec, parent_id, line) -> Tuple[str, str]:
560
+ """Generate a simple stack block with the given inputs.
561
+
562
+ inputs_spec: list of (input_name, input_kind, value_node_or_string)
563
+ """
564
+ block_id = gen_id()
565
+ inputs: Dict[str, list] = {}
566
+ for input_name, input_kind, val in inputs_spec:
567
+ # If val is already a string (e.g. broadcast msg), use as literal
568
+ if isinstance(val, str):
569
+ if input_kind == "broadcast":
570
+ shadow = [SHADOW_BROADCAST, val]
571
+ inputs[input_name] = [1, shadow]
572
+ else:
573
+ shadow = [SHADOW_STRING, val]
574
+ inputs[input_name] = [1, shadow]
575
+ else:
576
+ shadow, sub = self.gen_input_value(val, input_kind)
577
+ inputs[input_name] = self._pack_input(input_kind, shadow, sub)
578
+ self.current_blocks[block_id] = {
579
+ "opcode": opcode,
580
+ "next": None, "parent": parent_id,
581
+ "inputs": inputs,
582
+ "fields": {},
583
+ "shadow": False, "topLevel": False,
584
+ }
585
+ return block_id, block_id
586
+
587
+ def gen_wait_until(self, stmt: A.WaitUntilStatement, parent_id: Optional[str]):
588
+ """control_wait_until - has a CONDITION input (boolean)."""
589
+ shadow_c, sub_c = self.gen_input_value(stmt.cond, "boolean")
590
+ cond_input = self._pack_input("boolean", shadow_c, sub_c)
591
+ block_id = gen_id()
592
+ self.current_blocks[block_id] = {
593
+ "opcode": "control_wait_until",
594
+ "next": None, "parent": parent_id,
595
+ "inputs": {"CONDITION": cond_input},
596
+ "fields": {},
597
+ "shadow": False, "topLevel": False,
598
+ }
599
+ return block_id, block_id
600
+
601
+ # ---- variable assignment ---------------------------------------------
602
+ def gen_var_assign(self, stmt: A.VarAssign, parent_id: Optional[str]):
603
+ # Ensure var is declared
604
+ if stmt.name not in self.current_vars:
605
+ self._declare_var(stmt.name, stmt.value)
606
+ vid = self.current_vars[stmt.name]
607
+ block_id = gen_id()
608
+ shadow, sub_block = self.gen_input_value(stmt.value, "any")
609
+ inp = self._pack_input("any", shadow, sub_block)
610
+ self.current_blocks[block_id] = {
611
+ "opcode": "data_setvariableto",
612
+ "next": None,
613
+ "parent": parent_id,
614
+ "inputs": {"VALUE": inp},
615
+ "fields": {"VARIABLE": [stmt.name, vid]},
616
+ "shadow": False,
617
+ "topLevel": False,
618
+ }
619
+ return block_id, block_id
620
+
621
+ def gen_var_modify(self, stmt: A.VarModify, parent_id: Optional[str]):
622
+ if stmt.name not in self.current_vars:
623
+ self._declare_var(stmt.name, 0)
624
+ vid = self.current_vars[stmt.name]
625
+ # Compile += as: set var to (var + value), -= as set var to (var - value), etc.
626
+ # Actually Scratch has data_changevariableby only for +=.
627
+ # For -=, we need: change var by (0 - value)
628
+ # For *=, /= : set var to (var * value)
629
+ if stmt.op == "+=":
630
+ block_id = gen_id()
631
+ shadow, sub_block = self.gen_input_value(stmt.value, "number")
632
+ inp = self._pack_input("number", shadow, sub_block)
633
+ self.current_blocks[block_id] = {
634
+ "opcode": "data_changevariableby",
635
+ "next": None,
636
+ "parent": parent_id,
637
+ "inputs": {"VALUE": inp},
638
+ "fields": {"VARIABLE": [stmt.name, vid]},
639
+ "shadow": False,
640
+ "topLevel": False,
641
+ }
642
+ return block_id, block_id
643
+ else:
644
+ # General case: set var to (var OP value)
645
+ op_map = {"+=": "+", "-=": "-", "*=": "*", "/=": "/"}
646
+ op = op_map[stmt.op]
647
+ var_ref = A.VarRef(name=stmt.name, line=stmt.line)
648
+ new_value = A.BinOp(op=op, left=var_ref, right=stmt.value, line=stmt.line)
649
+ return self.gen_var_assign(A.VarAssign(name=stmt.name, value=new_value, line=stmt.line), parent_id)
650
+
651
+ # ---- list operations --------------------------------------------------
652
+ def gen_list_op(self, stmt: A.ListOp, parent_id: Optional[str]):
653
+ if stmt.list_name not in self.current_lists:
654
+ # auto-declare
655
+ lid = gen_id()
656
+ self.current_lists[stmt.list_name] = lid
657
+ self.current_list_values[stmt.list_name] = []
658
+ lid = self.current_lists[stmt.list_name]
659
+
660
+ if stmt.op == "add":
661
+ # data_addtolist: LIST, ITEM (ITEM is required)
662
+ if not stmt.args:
663
+ raise CompileError(
664
+ f'{stmt.list_name}.add() requires an argument, '
665
+ f'e.g. {stmt.list_name}.add(item)',
666
+ stmt.line
667
+ )
668
+ shadow, sub = self.gen_input_value(stmt.args[0], "string")
669
+ inp = self._pack_input("string", shadow, sub)
670
+ block_id = gen_id()
671
+ self.current_blocks[block_id] = {
672
+ "opcode": "data_addtolist",
673
+ "next": None, "parent": parent_id,
674
+ "inputs": {"ITEM": inp},
675
+ "fields": {"LIST": [stmt.list_name, lid]},
676
+ "shadow": False, "topLevel": False,
677
+ }
678
+ return block_id, block_id
679
+ if stmt.op == "delete":
680
+ # data_deleteoflist: LIST, INDEX (INDEX is required)
681
+ # special: index "all" -> data_deletealloflist
682
+ if stmt.args and isinstance(stmt.args[0], A.BareRef) and stmt.args[0].name.lower() == "all":
683
+ block_id = gen_id()
684
+ self.current_blocks[block_id] = {
685
+ "opcode": "data_deletealloflist",
686
+ "next": None, "parent": parent_id,
687
+ "inputs": {},
688
+ "fields": {"LIST": [stmt.list_name, lid]},
689
+ "shadow": False, "topLevel": False,
690
+ }
691
+ return block_id, block_id
692
+ if not stmt.args:
693
+ raise CompileError(
694
+ f'{stmt.list_name}.delete() requires an index argument, '
695
+ f'e.g. {stmt.list_name}.delete(1) or {stmt.list_name}.delete(all)',
696
+ stmt.line
697
+ )
698
+ shadow, sub = self.gen_input_value(stmt.args[0], "number")
699
+ inp = self._pack_input("number", shadow, sub)
700
+ block_id = gen_id()
701
+ self.current_blocks[block_id] = {
702
+ "opcode": "data_deleteoflist",
703
+ "next": None, "parent": parent_id,
704
+ "inputs": {"INDEX": inp},
705
+ "fields": {"LIST": [stmt.list_name, lid]},
706
+ "shadow": False, "topLevel": False,
707
+ }
708
+ return block_id, block_id
709
+ if stmt.op == "insert":
710
+ # data_insertatlist: LIST, INDEX, ITEM (both required)
711
+ # Syntax: list.insert(item, index)
712
+ if len(stmt.args) < 2:
713
+ raise CompileError(
714
+ f'{stmt.list_name}.insert() requires 2 arguments: '
715
+ f'item and index, e.g. {stmt.list_name}.insert(item, 1)',
716
+ stmt.line
717
+ )
718
+ shadow_idx, sub_idx = self.gen_input_value(stmt.args[1], "number")
719
+ shadow_item, sub_item = self.gen_input_value(stmt.args[0], "string")
720
+ block_id = gen_id()
721
+ self.current_blocks[block_id] = {
722
+ "opcode": "data_insertatlist",
723
+ "next": None, "parent": parent_id,
724
+ "inputs": {
725
+ "INDEX": self._pack_input("number", shadow_idx, sub_idx),
726
+ "ITEM": self._pack_input("string", shadow_item, sub_item),
727
+ },
728
+ "fields": {"LIST": [stmt.list_name, lid]},
729
+ "shadow": False, "topLevel": False,
730
+ }
731
+ return block_id, block_id
732
+ if stmt.op == "replace":
733
+ # data_replaceitemoflist: LIST, INDEX, ITEM (both required)
734
+ # Syntax: list.replace(index, item)
735
+ if len(stmt.args) < 2:
736
+ raise CompileError(
737
+ f'{stmt.list_name}.replace() requires 2 arguments: '
738
+ f'index and item, e.g. {stmt.list_name}.replace(1, item)',
739
+ stmt.line
740
+ )
741
+ shadow_idx, sub_idx = self.gen_input_value(stmt.args[0], "number")
742
+ shadow_item, sub_item = self.gen_input_value(stmt.args[1], "string")
743
+ block_id = gen_id()
744
+ self.current_blocks[block_id] = {
745
+ "opcode": "data_replaceitemoflist",
746
+ "next": None, "parent": parent_id,
747
+ "inputs": {
748
+ "INDEX": self._pack_input("number", shadow_idx, sub_idx),
749
+ "ITEM": self._pack_input("string", shadow_item, sub_item),
750
+ },
751
+ "fields": {"LIST": [stmt.list_name, lid]},
752
+ "shadow": False, "topLevel": False,
753
+ }
754
+ return block_id, block_id
755
+ if stmt.op == "show":
756
+ block_id = gen_id()
757
+ self.current_blocks[block_id] = {
758
+ "opcode": "data_showlist",
759
+ "next": None, "parent": parent_id,
760
+ "inputs": {},
761
+ "fields": {"LIST": [stmt.list_name, lid]},
762
+ "shadow": False, "topLevel": False,
763
+ }
764
+ return block_id, block_id
765
+ if stmt.op == "hide":
766
+ block_id = gen_id()
767
+ self.current_blocks[block_id] = {
768
+ "opcode": "data_hidelist",
769
+ "next": None, "parent": parent_id,
770
+ "inputs": {},
771
+ "fields": {"LIST": [stmt.list_name, lid]},
772
+ "shadow": False, "topLevel": False,
773
+ }
774
+ return block_id, block_id
775
+ raise CompileError(f"unknown list op: {stmt.op}", stmt.line)
776
+
777
+ # ---- control flow -----------------------------------------------------
778
+ def gen_if(self, stmt: A.IfStatement, parent_id: Optional[str]):
779
+ shadow_cond, sub_cond = self.gen_input_value(stmt.cond, "boolean")
780
+ cond_input = self._pack_input("boolean", shadow_cond, sub_cond)
781
+ then_first, then_last = self.gen_chain(stmt.then_body, parent_id=None)
782
+ block_id = gen_id()
783
+ inputs = {
784
+ "CONDITION": cond_input,
785
+ "SUBSTACK": self._pack_substack(then_first),
786
+ }
787
+ if stmt.else_body:
788
+ else_first, else_last = self.gen_chain(stmt.else_body, parent_id=None)
789
+ inputs["SUBSTACK2"] = self._pack_substack(else_first)
790
+ self.current_blocks[block_id] = {
791
+ "opcode": "control_if_else" if stmt.else_body else "control_if",
792
+ "next": None, "parent": parent_id,
793
+ "inputs": inputs,
794
+ "fields": {},
795
+ "shadow": False, "topLevel": False,
796
+ }
797
+ # Fix parents of substack chain
798
+ if then_first is not None:
799
+ self.current_blocks[then_first]["parent"] = block_id
800
+ if stmt.else_body and else_first is not None:
801
+ self.current_blocks[else_first]["parent"] = block_id
802
+ return block_id, block_id
803
+
804
+ def gen_repeat(self, stmt: A.RepeatStatement, parent_id: Optional[str]):
805
+ shadow_t, sub_t = self.gen_input_value(stmt.times, "number")
806
+ body_first, body_last = self.gen_chain(stmt.body, parent_id=None)
807
+ block_id = gen_id()
808
+ self.current_blocks[block_id] = {
809
+ "opcode": "control_repeat",
810
+ "next": None, "parent": parent_id,
811
+ "inputs": {
812
+ "TIMES": self._pack_input("number", shadow_t, sub_t),
813
+ "SUBSTACK": self._pack_substack(body_first),
814
+ },
815
+ "fields": {},
816
+ "shadow": False, "topLevel": False,
817
+ }
818
+ if body_first is not None:
819
+ self.current_blocks[body_first]["parent"] = block_id
820
+ return block_id, block_id
821
+
822
+ def gen_repeat_until(self, stmt: A.RepeatUntilStatement, parent_id: Optional[str]):
823
+ shadow_c, sub_c = self.gen_input_value(stmt.cond, "boolean")
824
+ body_first, body_last = self.gen_chain(stmt.body, parent_id=None)
825
+ block_id = gen_id()
826
+ self.current_blocks[block_id] = {
827
+ "opcode": "control_repeat_until",
828
+ "next": None, "parent": parent_id,
829
+ "inputs": {
830
+ "CONDITION": self._pack_input("boolean", shadow_c, sub_c),
831
+ "SUBSTACK": self._pack_substack(body_first),
832
+ },
833
+ "fields": {},
834
+ "shadow": False, "topLevel": False,
835
+ }
836
+ if body_first is not None:
837
+ self.current_blocks[body_first]["parent"] = block_id
838
+ return block_id, block_id
839
+
840
+ def gen_forever(self, stmt: A.ForeverStatement, parent_id: Optional[str]):
841
+ body_first, body_last = self.gen_chain(stmt.body, parent_id=None)
842
+ block_id = gen_id()
843
+ self.current_blocks[block_id] = {
844
+ "opcode": "control_forever",
845
+ "next": None, "parent": parent_id,
846
+ "inputs": {"SUBSTACK": self._pack_substack(body_first)},
847
+ "fields": {},
848
+ "shadow": False, "topLevel": False,
849
+ }
850
+ if body_first is not None:
851
+ self.current_blocks[body_first]["parent"] = block_id
852
+ # forever has no "next" - it loops forever
853
+ return block_id, block_id
854
+
855
+ def gen_while(self, stmt: A.WhileStatement, parent_id: Optional[str]):
856
+ # while (c) { body } => repeatUntil (not c) { body }
857
+ new_cond = A.UnaryOp(op="not", operand=stmt.cond, line=stmt.line)
858
+ ru = A.RepeatUntilStatement(cond=new_cond, body=stmt.body, line=stmt.line)
859
+ return self.gen_repeat_until(ru, parent_id)
860
+
861
+ def gen_for_each(self, stmt: A.ForEachStatement, parent_id: Optional[str]):
862
+ """control_for_each: VALUE input (count), VARIABLE field, SUBSTACK input.
863
+
864
+ The VARIABLE field uses the [name, id] format like a variable
865
+ reference, so the loop counter variable must be declared.
866
+ """
867
+ if stmt.var_name not in self.current_vars:
868
+ self._declare_var(stmt.var_name, 0)
869
+ vid = self.current_vars[stmt.var_name]
870
+ var_field = [stmt.var_name, vid]
871
+
872
+ shadow_c, sub_c = self.gen_input_value(stmt.count, "number")
873
+ body_first, body_last = self.gen_chain(stmt.body, parent_id=None)
874
+ block_id = gen_id()
875
+ self.current_blocks[block_id] = {
876
+ "opcode": "control_for_each",
877
+ "next": None, "parent": parent_id,
878
+ "inputs": {
879
+ "VALUE": self._pack_input("number", shadow_c, sub_c),
880
+ "SUBSTACK": self._pack_substack(body_first),
881
+ },
882
+ "fields": {"VARIABLE": var_field},
883
+ "shadow": False, "topLevel": False,
884
+ }
885
+ if body_first is not None:
886
+ self.current_blocks[body_first]["parent"] = block_id
887
+ return block_id, block_id
888
+
889
+ def gen_all_at_once(self, stmt: A.AllAtOnceStatement, parent_id: Optional[str]):
890
+ """control_all_at_once: SUBSTACK input only."""
891
+ body_first, body_last = self.gen_chain(stmt.body, parent_id=None)
892
+ block_id = gen_id()
893
+ self.current_blocks[block_id] = {
894
+ "opcode": "control_all_at_once",
895
+ "next": None, "parent": parent_id,
896
+ "inputs": {"SUBSTACK": self._pack_substack(body_first)},
897
+ "fields": {},
898
+ "shadow": False, "topLevel": False,
899
+ }
900
+ if body_first is not None:
901
+ self.current_blocks[body_first]["parent"] = block_id
902
+ return block_id, block_id
903
+
904
+ def gen_stop(self, stmt: A.StopStatement, parent_id: Optional[str]):
905
+ kind = B.STOP_OPTIONS.get(stmt.kind.lower(), stmt.kind)
906
+ block_id = gen_id()
907
+ self.current_blocks[block_id] = {
908
+ "opcode": "control_stop",
909
+ "next": None, "parent": parent_id,
910
+ "inputs": {},
911
+ "fields": {"STOP_OPTION": [kind, None]},
912
+ "shadow": False, "topLevel": False,
913
+ }
914
+ return block_id, block_id
915
+
916
+ def gen_broadcast(self, stmt: A.BroadcastStatement, parent_id: Optional[str]):
917
+ opcode = "event_broadcastandwait" if stmt.wait else "event_broadcast"
918
+ msg = self._resolve_broadcast_msg(stmt.msg)
919
+ # Generate a unique broadcast ID for this message
920
+ bcast_id = gen_id()
921
+ self.current_broadcasts[bcast_id] = msg
922
+ block_id = gen_id()
923
+ # Broadcast input: [shadow_type, [shadow_type, name, id]]
924
+ # The format is [1, [11, name, id]] where 11 = SHADOW_BROADCAST
925
+ shadow = [SHADOW_BROADCAST, msg, bcast_id]
926
+ self.current_blocks[block_id] = {
927
+ "opcode": opcode,
928
+ "next": None, "parent": parent_id,
929
+ "inputs": {"BROADCAST_INPUT": [1, shadow]},
930
+ "fields": {},
931
+ "shadow": False, "topLevel": False,
932
+ }
933
+ return block_id, block_id
934
+
935
+ def _resolve_broadcast_msg(self, msg_node) -> str:
936
+ if isinstance(msg_node, A.StringLit):
937
+ msg = msg_node.value
938
+ elif isinstance(msg_node, A.BareRef):
939
+ msg = msg_node.name
940
+ elif isinstance(msg_node, A.NumberLit):
941
+ msg = str(msg_node.value)
942
+ else:
943
+ # complex expr - use a placeholder
944
+ msg = "message"
945
+ return msg
946
+
947
+ # ---- generic stack block from table ----------------------------------
948
+ def gen_block_call(self, stmt: A.BlockCall, parent_id: Optional[str]):
949
+ # Resolve the key: "namespace.method" or "method"
950
+ if stmt.namespace:
951
+ key = f"{stmt.namespace}.{stmt.method}"
952
+ else:
953
+ key = stmt.method
954
+
955
+ # Special: say/think without post parens
956
+ effective_key = key
957
+ if key in ("say", "think") and not stmt.post:
958
+ effective_key = key + "_"
959
+
960
+ # Custom block call?
961
+ if not stmt.namespace and stmt.method in self.current_custom_blocks:
962
+ return self.gen_custom_block_call(stmt, parent_id)
963
+
964
+ spec = B.STACK_BLOCKS.get(effective_key)
965
+ if spec is None:
966
+ all_keys = list(B.STACK_BLOCKS.keys()) + list(self.current_custom_blocks.keys())
967
+ suggestion = did_you_mean(key, all_keys)
968
+ raise CompileError(
969
+ f'"{key}" is not valid TypeScratch syntax{suggestion}'
970
+ + (f" (did you mean to use it as a custom block?)" if not stmt.namespace and not suggestion else ""),
971
+ stmt.line
972
+ )
973
+
974
+ # Emit warning for hidden/experimental blocks
975
+ warn = spec.get("_warn")
976
+ if warn:
977
+ import sys
978
+ print(f"WARNING (line {stmt.line}): {warn}", file=sys.stderr)
979
+
980
+ block_id = gen_id()
981
+ inputs: Dict[str, list] = {}
982
+ fields: Dict[str, list] = {}
983
+
984
+ # Consume args from stmt.args and stmt.post in order
985
+ args_iter = list(stmt.args)
986
+ post_iter = list(stmt.post or [])
987
+ arg_idx = 0
988
+ post_idx = 0
989
+
990
+ # First, fields that come from args (None value)
991
+ for fname, fval in spec.get("fields", []):
992
+ if fval is None:
993
+ # value comes from next arg
994
+ if arg_idx < len(args_iter):
995
+ val_node = args_iter[arg_idx]
996
+ arg_idx += 1
997
+ fields[fname] = [self._resolve_field_value(val_node, fname), None]
998
+ else:
999
+ fields[fname] = ["", None]
1000
+ else:
1001
+ fields[fname] = [fval, None]
1002
+
1003
+ # Then, args (inputs)
1004
+ for spec_entry in spec.get("args", []):
1005
+ input_name = spec_entry[0]
1006
+ input_kind = spec_entry[1]
1007
+ source = spec_entry[2] if len(spec_entry) > 2 else "args"
1008
+ optional = spec_entry[3] if len(spec_entry) > 3 else False
1009
+
1010
+ if source == "args":
1011
+ if arg_idx < len(args_iter):
1012
+ val_node = args_iter[arg_idx]
1013
+ arg_idx += 1
1014
+ shadow, sub = self.gen_input_value(val_node, input_kind)
1015
+ inputs[input_name] = self._pack_input(input_kind, shadow, sub)
1016
+ elif post_idx < len(post_iter):
1017
+ # Allow falling through to post parens if args are exhausted.
1018
+ # This lets users write `changeEffect(color)(25)` as well as
1019
+ # `changeEffect(color, 25)`.
1020
+ val_node = post_iter[post_idx]
1021
+ post_idx += 1
1022
+ shadow, sub = self.gen_input_value(val_node, input_kind)
1023
+ inputs[input_name] = self._pack_input(input_kind, shadow, sub)
1024
+ elif optional:
1025
+ # provide default
1026
+ shadow, sub = self.gen_input_value(A.NumberLit(1), input_kind)
1027
+ inputs[input_name] = self._pack_input(input_kind, shadow, sub)
1028
+ else:
1029
+ raise CompileError(
1030
+ f"block {key!r} expects more arguments (input {input_name!r})",
1031
+ stmt.line
1032
+ )
1033
+ elif source == "post":
1034
+ if post_idx < len(post_iter):
1035
+ val_node = post_iter[post_idx]
1036
+ post_idx += 1
1037
+ shadow, sub = self.gen_input_value(val_node, input_kind)
1038
+ inputs[input_name] = self._pack_input(input_kind, shadow, sub)
1039
+ elif arg_idx < len(args_iter):
1040
+ # Allow falling back to args
1041
+ val_node = args_iter[arg_idx]
1042
+ arg_idx += 1
1043
+ shadow, sub = self.gen_input_value(val_node, input_kind)
1044
+ inputs[input_name] = self._pack_input(input_kind, shadow, sub)
1045
+ else:
1046
+ raise CompileError(
1047
+ f"block {key!r} expects a post-paren argument for {input_name!r} "
1048
+ f"(e.g. {key}(...)(value))",
1049
+ stmt.line
1050
+ )
1051
+
1052
+ # Special handling: if we have extra args for a string input, join them
1053
+ # This handles `say(Hello, World!)` where MESSAGE should be "Hello, World!"
1054
+ if arg_idx < len(args_iter):
1055
+ # find a string input we already filled and re-join
1056
+ for spec_entry in spec.get("args", []):
1057
+ input_name = spec_entry[0]
1058
+ input_kind = spec_entry[1]
1059
+ source = spec_entry[2] if len(spec_entry) > 2 else "args"
1060
+ if input_kind in ("string", "any") and source == "args" and arg_idx < len(args_iter):
1061
+ # Re-join: get the existing value and append remaining args
1062
+ existing = inputs.get(input_name)
1063
+ # existing is packed; we'll just create a new joined string
1064
+ existing_val = ""
1065
+ if existing and len(existing) > 1:
1066
+ prim = existing[1]
1067
+ if isinstance(prim, list) and len(prim) > 1:
1068
+ existing_val = prim[1]
1069
+ parts = [str(existing_val)]
1070
+ while arg_idx < len(args_iter):
1071
+ v = args_iter[arg_idx]
1072
+ arg_idx += 1
1073
+ parts.append(self._stringify(v))
1074
+ joined = ", ".join(parts) if parts[0] else ", ".join(parts[1:])
1075
+ shadow = [SHADOW_STRING, joined]
1076
+ inputs[input_name] = [1, shadow]
1077
+
1078
+ self.current_blocks[block_id] = {
1079
+ "opcode": spec["opcode"],
1080
+ "next": None, "parent": parent_id,
1081
+ "inputs": inputs,
1082
+ "fields": fields,
1083
+ "shadow": False, "topLevel": False,
1084
+ }
1085
+ return block_id, block_id
1086
+
1087
+ def _resolve_field_value(self, val_node, fname: str) -> str:
1088
+ """Resolve an arg to a field value (string)."""
1089
+ if isinstance(val_node, A.StringLit):
1090
+ return val_node.value
1091
+ if isinstance(val_node, A.NumberLit):
1092
+ return str(val_node.value)
1093
+ if isinstance(val_node, A.BareRef):
1094
+ # Check if it matches a menu option
1095
+ return val_node.name
1096
+ if isinstance(val_node, A.ColorLit):
1097
+ return val_node.value
1098
+ raise CompileError(f"cannot use complex expression as field value for {fname!r}", val_node.line if hasattr(val_node, 'line') else 0)
1099
+
1100
+ def _stringify(self, val_node) -> str:
1101
+ if isinstance(val_node, A.StringLit):
1102
+ return val_node.value
1103
+ if isinstance(val_node, A.NumberLit):
1104
+ return str(val_node.value)
1105
+ if isinstance(val_node, A.BareRef):
1106
+ return val_node.name
1107
+ if isinstance(val_node, A.ColorLit):
1108
+ return val_node.value
1109
+ return ""
1110
+
1111
+ # ---- custom blocks ----------------------------------------------------
1112
+ def gen_custom_block_def(self, cb: A.CustomBlockDef) -> None:
1113
+ # Build proccode: "name %s %n %b" based on param types
1114
+ type_codes = {"string": "%s", "number": "%n", "boolean": "%b"}
1115
+ proccode = cb.name + "".join(" " + type_codes.get(p["type"], "%s") for p in cb.params)
1116
+ argument_ids = [gen_id() for _ in cb.params]
1117
+ argument_names = [p["name"] for p in cb.params]
1118
+ argument_defaults = []
1119
+ for p in cb.params:
1120
+ if p["type"] == "number":
1121
+ argument_defaults.append(0)
1122
+ elif p["type"] == "boolean":
1123
+ argument_defaults.append(False)
1124
+ else:
1125
+ argument_defaults.append("")
1126
+
1127
+ self.current_custom_blocks[cb.name] = {
1128
+ "proccode": proccode,
1129
+ "argument_ids": argument_ids,
1130
+ "argument_names": argument_names,
1131
+ "argument_defaults": argument_defaults,
1132
+ "warp": cb.warp,
1133
+ }
1134
+
1135
+ # Generate the prototype block (procedures_prototype)
1136
+ # In real Scratch, the prototype has EMPTY inputs and shadow=false.
1137
+ # The argument info lives entirely in the mutation.
1138
+ proto_id = gen_id()
1139
+ proto_block = {
1140
+ "opcode": "procedures_prototype",
1141
+ "next": None,
1142
+ "parent": None,
1143
+ "inputs": {},
1144
+ "fields": {},
1145
+ "shadow": False,
1146
+ "topLevel": False,
1147
+ }
1148
+ # mutation - this is where the argument info lives
1149
+ proto_block["mutation"] = {
1150
+ "tagName": "mutation",
1151
+ "children": [],
1152
+ "proccode": proccode,
1153
+ "argumentids": "[]",
1154
+ "warp": "true" if cb.warp else "false",
1155
+ "argumentdefaults": "[]",
1156
+ "argumentnames": "[]",
1157
+ }
1158
+ # JSON-encode the lists for mutation
1159
+ import json as _json
1160
+ proto_block["mutation"]["argumentids"] = _json.dumps(argument_ids)
1161
+ proto_block["mutation"]["argumentdefaults"] = _json.dumps(argument_defaults)
1162
+ proto_block["mutation"]["argumentnames"] = _json.dumps(argument_names)
1163
+
1164
+ self.current_blocks[proto_id] = proto_block
1165
+
1166
+ # Generate the definition block (procedures_definition)
1167
+ def_id = gen_id()
1168
+ # Set the current proc params so body expressions can resolve
1169
+ # parameter names to argument_reporter blocks.
1170
+ old_params = self.current_proc_params
1171
+ self.current_proc_params = {
1172
+ p["name"]: {"arg_id": arg_id, "type": p["type"]}
1173
+ for p, arg_id in zip(cb.params, argument_ids)
1174
+ }
1175
+ # The custom block body is the chain inside the def
1176
+ body_first, body_last = self.gen_chain(cb.body, parent_id=None)
1177
+ self.current_proc_params = old_params
1178
+
1179
+ self.current_blocks[def_id] = {
1180
+ "opcode": "procedures_definition",
1181
+ "next": body_first,
1182
+ "parent": None,
1183
+ "inputs": {"custom_block": [2, proto_id]},
1184
+ "fields": {},
1185
+ "shadow": False,
1186
+ "topLevel": True,
1187
+ "x": 0,
1188
+ "y": 0,
1189
+ }
1190
+ if body_first is not None:
1191
+ self.current_blocks[body_first]["parent"] = def_id
1192
+
1193
+ # Store the body_first for reference (for calls that need to know)
1194
+ self.current_custom_blocks[cb.name]["def_block_id"] = def_id
1195
+ self.current_custom_blocks[cb.name]["proto_block_id"] = proto_id
1196
+
1197
+ def gen_custom_block_call(self, stmt: A.BlockCall, parent_id: Optional[str]):
1198
+ info = self.current_custom_blocks[stmt.method]
1199
+ block_id = gen_id()
1200
+ # arguments: each input is one of the argument_ids
1201
+ inputs = {}
1202
+ for arg_id, arg_node in zip(info["argument_ids"], stmt.args):
1203
+ # determine kind from param type
1204
+ param_types = info.get("argument_types") or ["string"] * len(info["argument_ids"])
1205
+ # actually we need the param types - re-derive from proccode? store them
1206
+ # We'll use 'any' to be flexible
1207
+ shadow, sub = self.gen_input_value(arg_node, "any")
1208
+ inputs[arg_id] = self._pack_input("any", shadow, sub)
1209
+ self.current_blocks[block_id] = {
1210
+ "opcode": "procedures_call",
1211
+ "next": None, "parent": parent_id,
1212
+ "inputs": inputs,
1213
+ "fields": {},
1214
+ "shadow": False, "topLevel": False,
1215
+ "mutation": {
1216
+ "tagName": "mutation",
1217
+ "children": [],
1218
+ "proccode": info["proccode"],
1219
+ "argumentids": _json_dumps(info["argument_ids"]),
1220
+ "warp": "true" if info["warp"] else "false",
1221
+ },
1222
+ }
1223
+ return block_id, block_id
1224
+
1225
+ # =====================================================================
1226
+ # Expressions
1227
+ # =====================================================================
1228
+ def gen_input_value(self, expr, kind: str) -> Tuple[Optional[list], Optional[str]]:
1229
+ """Generate an input value.
1230
+ Returns (shadow_primitive, block_id_or_None).
1231
+ One of them is None:
1232
+ - For literal shadow: ([prim_type, value], None)
1233
+ - For block input: (None, block_id)
1234
+ - For variable: ([12, name, id], None) - and we'll pack as [3, ...]
1235
+ """
1236
+ if expr is None:
1237
+ return self._default_shadow(kind), None
1238
+
1239
+ # Plain Python values (str, int, float) - happen when broadcast
1240
+ # messages come back as strings, etc.
1241
+ if isinstance(expr, str):
1242
+ if kind == "broadcast":
1243
+ self.current_broadcasts[expr] = expr
1244
+ return [SHADOW_BROADCAST, expr], None
1245
+ return [SHADOW_STRING, expr], None
1246
+ if isinstance(expr, (int, float)):
1247
+ return [SHADOW_NUMBER, str(expr)], None
1248
+
1249
+ # Resolve BareRef first
1250
+ if isinstance(expr, A.BareRef):
1251
+ expr = self._resolve_bare_ref(expr)
1252
+
1253
+ if isinstance(expr, A.NumberLit):
1254
+ val = expr.value
1255
+ return [SHADOW_NUMBER, str(val)], None
1256
+ if isinstance(expr, A.StringLit):
1257
+ return [SHADOW_STRING, expr.value], None
1258
+ if isinstance(expr, A.ColorLit):
1259
+ return [SHADOW_COLOR, expr.value], None
1260
+ if isinstance(expr, A.VarRef):
1261
+ vid = self.current_vars.get(expr.name)
1262
+ if vid is None:
1263
+ # auto-declare (shouldn't normally happen)
1264
+ self._declare_var(expr.name, 0)
1265
+ vid = self.current_vars[expr.name]
1266
+ return [SHADOW_VAR, expr.name, vid], None
1267
+ if isinstance(expr, A.ArgumentRef):
1268
+ # Generate an argument_reporter block.
1269
+ # In real Scratch, argument_reporter blocks inside a custom block
1270
+ # body have shadow=false (they're real blocks, not shadows).
1271
+ arg_type = getattr(expr, "_arg_type", "string")
1272
+ opcode = "argument_reporter_boolean" if arg_type == "boolean" else "argument_reporter_string_number"
1273
+ block_id = gen_id()
1274
+ self.current_blocks[block_id] = {
1275
+ "opcode": opcode,
1276
+ "next": None, "parent": None,
1277
+ "inputs": {},
1278
+ "fields": {"VALUE": [expr.name, None]},
1279
+ "shadow": False, "topLevel": False,
1280
+ }
1281
+ return None, block_id
1282
+ if isinstance(expr, A.ListReporterRef):
1283
+ return self._gen_list_reporter(expr)
1284
+ if isinstance(expr, A.BuiltinRef):
1285
+ # Builtin reporter (e.g. answer, mouseX)
1286
+ spec = B.REPORTERS.get(expr.name)
1287
+ if spec is None:
1288
+ # try alias
1289
+ alias = B.BUILTIN_ALIASES.get(expr.name.lower())
1290
+ if alias:
1291
+ spec = B.REPORTERS.get(alias)
1292
+ if spec is None:
1293
+ suggestion = did_you_mean(expr.name, B.REPORTERS.keys())
1294
+ raise CompileError(
1295
+ f'"{expr.name}" is not valid TypeScratch syntax{suggestion}',
1296
+ expr.line
1297
+ )
1298
+ return self._gen_reporter_block(spec, [], expr.line)
1299
+ if isinstance(expr, A.FuncCall):
1300
+ # Could be: reporter (pickRandom, join, abs, etc.)
1301
+ # or math op like Math.abs
1302
+ name = expr.name
1303
+ spec = B.REPORTERS.get(name)
1304
+ if spec is None:
1305
+ # try alias
1306
+ alias = B.BUILTIN_ALIASES.get(name.lower())
1307
+ if alias:
1308
+ spec = B.REPORTERS.get(alias)
1309
+ if spec is None:
1310
+ suggestion = did_you_mean(name, B.REPORTERS.keys())
1311
+ raise CompileError(
1312
+ f'"{name}" is not valid TypeScratch syntax{suggestion}',
1313
+ expr.line
1314
+ )
1315
+ return self._gen_reporter_block(spec, expr.args, expr.line)
1316
+ if isinstance(expr, A.BinOp):
1317
+ return self._gen_binop(expr)
1318
+ if isinstance(expr, A.UnaryOp):
1319
+ return self._gen_unaryop(expr)
1320
+ raise CompileError(f"cannot generate expression: {type(expr).__name__}", getattr(expr, 'line', 0))
1321
+
1322
+ def _resolve_bare_ref(self, ref: A.BareRef):
1323
+ """Resolve a BareRef into VarRef / ArgumentRef / BuiltinRef / StringLit."""
1324
+ name = ref.name
1325
+ if name in self.current_vars:
1326
+ return A.VarRef(name=name, line=ref.line)
1327
+ # custom block parameter?
1328
+ if name in self.current_proc_params:
1329
+ param_info = self.current_proc_params[name]
1330
+ ref = A.ArgumentRef(name=name, arg_id=param_info["arg_id"], line=ref.line)
1331
+ ref._arg_type = param_info["type"]
1332
+ return ref
1333
+ # check builtin aliases (case-insensitive)
1334
+ lower = name.lower()
1335
+ if lower in B.BUILTIN_ALIASES:
1336
+ return A.BuiltinRef(name=B.BUILTIN_ALIASES[lower], line=ref.line)
1337
+ # check direct reporter name
1338
+ if name in B.REPORTERS:
1339
+ return A.BuiltinRef(name=name, line=ref.line)
1340
+ # default: string literal
1341
+ return A.StringLit(value=name, line=ref.line)
1342
+
1343
+ def _gen_list_reporter(self, expr: A.ListReporterRef) -> Tuple[Optional[list], Optional[str]]:
1344
+ """Generate a list reporter block (data_itemoflist, data_lengthoflist,
1345
+ data_listcontainsitem, data_itemnumoflist, data_listcontents).
1346
+
1347
+ The list reference is a FIELD with [name, id] format."""
1348
+ # Make sure the list is declared
1349
+ if expr.list_name not in self.current_lists:
1350
+ lid = gen_id()
1351
+ self.current_lists[expr.list_name] = lid
1352
+ self.current_list_values[expr.list_name] = []
1353
+ lid = self.current_lists[expr.list_name]
1354
+ list_field = [expr.list_name, lid]
1355
+
1356
+ method = expr.method
1357
+ # Normalize aliases
1358
+ if method == "itemNumber":
1359
+ method = "itemNum"
1360
+ if method == "asString":
1361
+ method = "contents"
1362
+
1363
+ block_id = gen_id()
1364
+
1365
+ if method == "item":
1366
+ # data_itemoflist: INDEX input, LIST field (INDEX is required)
1367
+ if not expr.args:
1368
+ raise CompileError(
1369
+ f'{expr.list_name}.item() requires an index argument, '
1370
+ f'e.g. {expr.list_name}.item(1)',
1371
+ expr.line
1372
+ )
1373
+ idx_node = expr.args[0]
1374
+ shadow, sub = self.gen_input_value(idx_node, "number")
1375
+ self.current_blocks[block_id] = {
1376
+ "opcode": "data_itemoflist",
1377
+ "next": None, "parent": None,
1378
+ "inputs": {"INDEX": self._pack_input("number", shadow, sub)},
1379
+ "fields": {"LIST": list_field},
1380
+ "shadow": False, "topLevel": False,
1381
+ }
1382
+ return None, block_id
1383
+
1384
+ if method == "length":
1385
+ # data_lengthoflist: just LIST field
1386
+ self.current_blocks[block_id] = {
1387
+ "opcode": "data_lengthoflist",
1388
+ "next": None, "parent": None,
1389
+ "inputs": {},
1390
+ "fields": {"LIST": list_field},
1391
+ "shadow": False, "topLevel": False,
1392
+ }
1393
+ return None, block_id
1394
+
1395
+ if method == "contains":
1396
+ # data_listcontainsitem: ITEM input, LIST field (ITEM is required)
1397
+ if not expr.args:
1398
+ raise CompileError(
1399
+ f'{expr.list_name}.contains() requires an argument, '
1400
+ f'e.g. {expr.list_name}.contains(item)',
1401
+ expr.line
1402
+ )
1403
+ item_node = expr.args[0]
1404
+ shadow, sub = self.gen_input_value(item_node, "string")
1405
+ self.current_blocks[block_id] = {
1406
+ "opcode": "data_listcontainsitem",
1407
+ "next": None, "parent": None,
1408
+ "inputs": {"ITEM": self._pack_input("string", shadow, sub)},
1409
+ "fields": {"LIST": list_field},
1410
+ "shadow": False, "topLevel": False,
1411
+ }
1412
+ return None, block_id
1413
+
1414
+ if method == "itemNum":
1415
+ # data_itemnumoflist: ITEM input, LIST field (ITEM is required)
1416
+ if not expr.args:
1417
+ raise CompileError(
1418
+ f'{expr.list_name}.itemNum() requires an argument, '
1419
+ f'e.g. {expr.list_name}.itemNum(item)',
1420
+ expr.line
1421
+ )
1422
+ item_node = expr.args[0]
1423
+ shadow, sub = self.gen_input_value(item_node, "string")
1424
+ self.current_blocks[block_id] = {
1425
+ "opcode": "data_itemnumoflist",
1426
+ "next": None, "parent": None,
1427
+ "inputs": {"ITEM": self._pack_input("string", shadow, sub)},
1428
+ "fields": {"LIST": list_field},
1429
+ "shadow": False, "topLevel": False,
1430
+ }
1431
+ return None, block_id
1432
+
1433
+ if method == "contents":
1434
+ # data_listcontents: just LIST field
1435
+ self.current_blocks[block_id] = {
1436
+ "opcode": "data_listcontents",
1437
+ "next": None, "parent": None,
1438
+ "inputs": {},
1439
+ "fields": {"LIST": list_field},
1440
+ "shadow": False, "topLevel": False,
1441
+ }
1442
+ return None, block_id
1443
+
1444
+ raise CompileError(
1445
+ f"unknown list reporter method: {method!r} "
1446
+ f"(supported: item, length, contains, itemNum, contents)",
1447
+ expr.line
1448
+ )
1449
+
1450
+ def _gen_reporter_block(self, spec, args, line) -> Tuple[Optional[list], Optional[str]]:
1451
+ # Emit warning for hidden/experimental reporters
1452
+ warn = spec.get("_warn")
1453
+ if warn:
1454
+ import sys
1455
+ print(f"WARNING (line {line}): {warn}", file=sys.stderr)
1456
+
1457
+ block_id = gen_id()
1458
+ inputs = {}
1459
+ fields = {}
1460
+ arg_idx = 0
1461
+ for fname, fval in spec.get("fields", []):
1462
+ if fval is None:
1463
+ if arg_idx < len(args):
1464
+ val_node = args[arg_idx]
1465
+ arg_idx += 1
1466
+ fields[fname] = [self._resolve_field_value(val_node, fname), None]
1467
+ else:
1468
+ fields[fname] = ["", None]
1469
+ else:
1470
+ fields[fname] = [fval, None]
1471
+ for spec_entry in spec.get("args", []):
1472
+ input_name = spec_entry[0]
1473
+ input_kind = spec_entry[1]
1474
+ if arg_idx < len(args):
1475
+ val_node = args[arg_idx]
1476
+ arg_idx += 1
1477
+ shadow, sub = self.gen_input_value(val_node, input_kind)
1478
+ inputs[input_name] = self._pack_input(input_kind, shadow, sub)
1479
+ else:
1480
+ shadow, sub = self.gen_input_value(A.NumberLit(0), input_kind)
1481
+ inputs[input_name] = self._pack_input(input_kind, shadow, sub)
1482
+ self.current_blocks[block_id] = {
1483
+ "opcode": spec["opcode"],
1484
+ "next": None, "parent": None,
1485
+ "inputs": inputs,
1486
+ "fields": fields,
1487
+ "shadow": False, "topLevel": False,
1488
+ }
1489
+ return None, block_id
1490
+
1491
+ def _gen_binop(self, expr: A.BinOp) -> Tuple[Optional[list], Optional[str]]:
1492
+ op = expr.op
1493
+ op_to_opcode = {
1494
+ "+": "operator_add",
1495
+ "-": "operator_subtract",
1496
+ "*": "operator_multiply",
1497
+ "/": "operator_divide",
1498
+ "mod": "operator_mod",
1499
+ ">": "operator_gt",
1500
+ "<": "operator_lt",
1501
+ "=": "operator_equals",
1502
+ "and": "operator_and",
1503
+ "or": "operator_or",
1504
+ }
1505
+ opcode = op_to_opcode.get(op)
1506
+ if opcode is None:
1507
+ raise CompileError(f"unknown binary operator: {op!r}", expr.line)
1508
+
1509
+ block_id = gen_id()
1510
+ # Determine input kind and input names based on operator type
1511
+ # Math operators (+,-,*,/,mod) use NUM1/NUM2
1512
+ # Comparison operators (>,<,=) use OPERAND1/OPERAND2
1513
+ # Logic operators (and,or) use OPERAND1/OPERAND2
1514
+ if op in ("+", "-", "*", "/", "mod"):
1515
+ input1_name = "NUM1"
1516
+ input2_name = "NUM2"
1517
+ kind = "number"
1518
+ elif op in (">", "<", "="):
1519
+ input1_name = "OPERAND1"
1520
+ input2_name = "OPERAND2"
1521
+ kind = "any"
1522
+ else: # and, or
1523
+ input1_name = "OPERAND1"
1524
+ input2_name = "OPERAND2"
1525
+ kind = "boolean"
1526
+ left_shadow, left_sub = self.gen_input_value(expr.left, kind)
1527
+ right_shadow, right_sub = self.gen_input_value(expr.right, kind)
1528
+ inputs = {
1529
+ input1_name: self._pack_input(kind, left_shadow, left_sub),
1530
+ input2_name: self._pack_input(kind, right_shadow, right_sub),
1531
+ }
1532
+ self.current_blocks[block_id] = {
1533
+ "opcode": opcode,
1534
+ "next": None, "parent": None,
1535
+ "inputs": inputs,
1536
+ "fields": {},
1537
+ "shadow": False, "topLevel": False,
1538
+ }
1539
+ return None, block_id
1540
+
1541
+ def _gen_unaryop(self, expr: A.UnaryOp) -> Tuple[Optional[list], Optional[str]]:
1542
+ op = expr.op
1543
+ if op == "not":
1544
+ block_id = gen_id()
1545
+ shadow, sub = self.gen_input_value(expr.operand, "boolean")
1546
+ self.current_blocks[block_id] = {
1547
+ "opcode": "operator_not",
1548
+ "next": None, "parent": None,
1549
+ "inputs": {"OPERAND": self._pack_input("boolean", shadow, sub)},
1550
+ "fields": {},
1551
+ "shadow": False, "topLevel": False,
1552
+ }
1553
+ return None, block_id
1554
+ if op == "-":
1555
+ # negate: 0 - operand
1556
+ zero = A.NumberLit(0)
1557
+ new_expr = A.BinOp(op="-", left=zero, right=expr.operand, line=expr.line)
1558
+ return self._gen_binop(new_expr)
1559
+ raise CompileError(f"unknown unary operator: {op!r}", expr.line)
1560
+
1561
+ # =====================================================================
1562
+ # Input packing
1563
+ # =====================================================================
1564
+ def _pack_input(self, kind: str, shadow: Optional[list], block_id: Optional[str]) -> list:
1565
+ """Pack a shadow/block into the Scratch input format.
1566
+
1567
+ Scratch 3 input formats:
1568
+ [1, [prim, val]] - pure shadow (literal value)
1569
+ [2, block_id] - block only, no shadow (substacks, booleans)
1570
+ [2, block_id, [prim, val]] - block with shadow (reporter covering default)
1571
+ [3, [12, name, id]] - variable reference
1572
+ [3, block_id, [prim, val]] - block with shadow (alt form, used by reporters)
1573
+
1574
+ When a reporter covers an input, we MUST include the shadow so that
1575
+ removing the reporter in the editor shows a default value instead of
1576
+ a "hole". This is the key fix for the hole bug.
1577
+ """
1578
+ # Variable input - use format [3, [12, name, id]]
1579
+ if shadow is not None and len(shadow) > 0 and shadow[0] == SHADOW_VAR:
1580
+ return [3, shadow]
1581
+ # List input (similar)
1582
+ if shadow is not None and len(shadow) > 0 and shadow[0] == SHADOW_LIST:
1583
+ return [3, shadow]
1584
+ # Broadcast input
1585
+ if shadow is not None and len(shadow) > 0 and shadow[0] == SHADOW_BROADCAST:
1586
+ if block_id is None:
1587
+ return [1, shadow]
1588
+ return [2, block_id, shadow]
1589
+ # Block covering a shadow - MUST include the shadow so the editor
1590
+ # shows a default value when the reporter is removed.
1591
+ # Use format [3, block_id, shadow] (shadow type 3) like real Scratch.
1592
+ if block_id is not None and shadow is not None:
1593
+ return [3, block_id, shadow]
1594
+ # Block only (no shadow provided) - generate a default shadow based
1595
+ # on the input kind so the editor doesn't show a "hole" when the
1596
+ # reporter is removed.
1597
+ if block_id is not None and shadow is None:
1598
+ default = self._default_shadow(kind)
1599
+ if default is not None:
1600
+ return [3, block_id, default]
1601
+ return [2, block_id]
1602
+ # Pure shadow
1603
+ if shadow is not None:
1604
+ return [1, shadow]
1605
+ # Empty - shouldn't happen
1606
+ return [1, self._default_shadow(kind)]
1607
+
1608
+ def _pack_substack(self, first_block_id: Optional[str]) -> list:
1609
+ if first_block_id is None:
1610
+ return [2, None] # empty substack
1611
+ return [2, first_block_id]
1612
+
1613
+ def _default_shadow(self, kind: str) -> list:
1614
+ if kind == "number":
1615
+ return [SHADOW_NUMBER, ""]
1616
+ if kind == "color":
1617
+ return [SHADOW_COLOR, "#000000"]
1618
+ if kind == "boolean":
1619
+ return None # no shadow for booleans
1620
+ if kind == "broadcast":
1621
+ return [SHADOW_BROADCAST, ""]
1622
+ return [SHADOW_STRING, ""]
1623
+
1624
+
1625
+ def _md5(data: bytes) -> str:
1626
+ return hashlib.md5(data).hexdigest()
1627
+
1628
+
1629
+ def _json_dumps(obj) -> str:
1630
+ import json
1631
+ return json.dumps(obj)