inscript-lang 2.2.0__tar.gz → 2.4.0__tar.gz

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 (30) hide show
  1. {inscript_lang-2.2.0 → inscript_lang-2.4.0}/PKG-INFO +1 -1
  2. {inscript_lang-2.2.0 → inscript_lang-2.4.0}/ast_nodes.py +5 -0
  3. {inscript_lang-2.2.0 → inscript_lang-2.4.0}/inscript.py +174 -4
  4. {inscript_lang-2.2.0 → inscript_lang-2.4.0}/inscript_lang.egg-info/PKG-INFO +1 -1
  5. {inscript_lang-2.2.0 → inscript_lang-2.4.0}/interpreter.py +159 -20
  6. {inscript_lang-2.2.0 → inscript_lang-2.4.0}/parser.py +33 -0
  7. {inscript_lang-2.2.0 → inscript_lang-2.4.0}/pyproject.toml +1 -1
  8. {inscript_lang-2.2.0 → inscript_lang-2.4.0}/repl.py +1 -1
  9. {inscript_lang-2.2.0 → inscript_lang-2.4.0}/stdlib.py +71 -0
  10. {inscript_lang-2.2.0 → inscript_lang-2.4.0}/stdlib_values.py +148 -0
  11. {inscript_lang-2.2.0 → inscript_lang-2.4.0}/README.md +0 -0
  12. {inscript_lang-2.2.0 → inscript_lang-2.4.0}/analyzer.py +0 -0
  13. {inscript_lang-2.2.0 → inscript_lang-2.4.0}/compiler.py +0 -0
  14. {inscript_lang-2.2.0 → inscript_lang-2.4.0}/environment.py +0 -0
  15. {inscript_lang-2.2.0 → inscript_lang-2.4.0}/errors.py +0 -0
  16. {inscript_lang-2.2.0 → inscript_lang-2.4.0}/inscript_fmt.py +0 -0
  17. {inscript_lang-2.2.0 → inscript_lang-2.4.0}/inscript_lang.egg-info/SOURCES.txt +0 -0
  18. {inscript_lang-2.2.0 → inscript_lang-2.4.0}/inscript_lang.egg-info/dependency_links.txt +0 -0
  19. {inscript_lang-2.2.0 → inscript_lang-2.4.0}/inscript_lang.egg-info/entry_points.txt +0 -0
  20. {inscript_lang-2.2.0 → inscript_lang-2.4.0}/inscript_lang.egg-info/requires.txt +0 -0
  21. {inscript_lang-2.2.0 → inscript_lang-2.4.0}/inscript_lang.egg-info/top_level.txt +0 -0
  22. {inscript_lang-2.2.0 → inscript_lang-2.4.0}/inscript_test.py +0 -0
  23. {inscript_lang-2.2.0 → inscript_lang-2.4.0}/lexer.py +0 -0
  24. {inscript_lang-2.2.0 → inscript_lang-2.4.0}/pygame_backend.py +0 -0
  25. {inscript_lang-2.2.0 → inscript_lang-2.4.0}/setup.cfg +0 -0
  26. {inscript_lang-2.2.0 → inscript_lang-2.4.0}/setup.py +0 -0
  27. {inscript_lang-2.2.0 → inscript_lang-2.4.0}/stdlib_extended.py +0 -0
  28. {inscript_lang-2.2.0 → inscript_lang-2.4.0}/stdlib_extended_2.py +0 -0
  29. {inscript_lang-2.2.0 → inscript_lang-2.4.0}/stdlib_game.py +0 -0
  30. {inscript_lang-2.2.0 → inscript_lang-2.4.0}/vm.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: inscript-lang
3
- Version: 2.2.0
3
+ Version: 2.4.0
4
4
  Summary: InScript — a game-focused scripting language with 59 game modules and a bytecode VM
5
5
  Author: Shreyasi Sarkar
6
6
  License: MIT
@@ -751,3 +751,8 @@ class DestructParam(Node):
751
751
  names: list # list of str (field/element names to bind)
752
752
  rest: object # str name for rest element (...tail), or None
753
753
  type_ann: object # TypeAnnotation or None
754
+
755
+ @dataclass
756
+ class TaskSpawnExpr(Node):
757
+ """v2.3.0: spawn <expr> — run expression concurrently, return InScriptTask."""
758
+ expr: object
@@ -24,7 +24,7 @@ from errors import (InScriptError, LexerError, ParseError,
24
24
  SemanticError, InScriptRuntimeError,
25
25
  MultiError, InScriptWarning)
26
26
 
27
- VERSION = "2.2.0"
27
+ VERSION = "2.4.0"
28
28
 
29
29
  MANIFEST_FILENAME = "inscript.toml"
30
30
  LOCK_FILENAME = "inscript.lock"
@@ -389,19 +389,166 @@ def info_package(pkg_name: str) -> int:
389
389
 
390
390
 
391
391
  def run_file(path: str, type_check: bool = True) -> int:
392
- """Load and execute an InScript source file. Returns exit code."""
392
+ """Load and execute an InScript source file (or .ibc bytecode). Returns exit code."""
393
393
  if not os.path.exists(path):
394
394
  print(f"[InScript] Error: file not found: '{path}'", file=sys.stderr)
395
395
  return 1
396
+ # v2.4.0: transparent .ibc execution via bytecode VM
397
+ if path.endswith(".ibc"):
398
+ return _run_ibc(path)
396
399
  if not path.endswith(".ins"):
397
400
  print(f"[InScript] Warning: expected .ins extension, got '{path}'", file=sys.stderr)
398
-
399
401
  with open(path, "r", encoding="utf-8") as f:
400
402
  source = f.read()
401
-
402
403
  return run_source(source, filename=path, type_check=type_check)
403
404
 
404
405
 
406
+ # ── v2.4.0: Real Bytecode Pipeline ───────────────────────────────────────────
407
+
408
+ def _ibc_path_for(src: str, out: str | None = None) -> str:
409
+ if out:
410
+ return out
411
+ base = src[:-4] if src.endswith(".ins") else src
412
+ return base + ".ibc"
413
+
414
+
415
+ def _compile_cmd(src_path: str, out_path, target: str,
416
+ dce: bool, incremental: bool) -> int:
417
+ """v2.4.0: AOT-compile .ins → .ibc using the real register-based VM compiler."""
418
+ if not os.path.exists(src_path):
419
+ print(f"[InScript] compile: file not found: '{src_path}'", file=sys.stderr)
420
+ return 1
421
+
422
+ # WASM / native — honest stubs with roadmap info
423
+ if target in ("wasm", "native"):
424
+ print(f"[InScript] --target {target} is planned for v2.5.x.")
425
+ print(f" WASM output: requires LLVM/Emscripten toolchain")
426
+ print(f" Native output: requires transpile-to-C pipeline")
427
+ print(f" Today's target: inscript --compile {src_path} --target ibc")
428
+ return 0
429
+
430
+ dest = _ibc_path_for(src_path, out_path)
431
+
432
+ # Incremental: skip if .ibc is up to date
433
+ if incremental and os.path.exists(dest):
434
+ if os.path.getmtime(dest) >= os.path.getmtime(src_path):
435
+ print(f"[InScript] compile: {dest} is up to date (--incremental)")
436
+ return 0
437
+
438
+ with open(src_path, "r", encoding="utf-8") as f:
439
+ source = f.read()
440
+
441
+ # Parse + compile to FnProto bytecode
442
+ try:
443
+ from compiler import compile_source
444
+ from vm_code import write_ibc
445
+ proto = compile_source(source, filename=src_path)
446
+ except Exception as e:
447
+ print(f"[InScript] compile error: {e}", file=sys.stderr)
448
+ return 1
449
+
450
+ # Dead-code elimination at the proto level
451
+ if dce:
452
+ n_removed = _dce_proto(proto)
453
+ if n_removed:
454
+ print(f"[InScript] DCE: pruned {n_removed} unreachable nested proto(s)")
455
+
456
+ # Write .ibc
457
+ try:
458
+ write_ibc(proto, dest)
459
+ except Exception as e:
460
+ print(f"[InScript] serialize error: {e}", file=sys.stderr)
461
+ return 1
462
+
463
+ src_size = os.path.getsize(src_path)
464
+ ibc_size = os.path.getsize(dest)
465
+ dce_tag = " +DCE" if dce else ""
466
+ print(f"[InScript] compiled{dce_tag}: {src_path} → {dest}")
467
+ print(f" source {src_size:,}B → bytecode {ibc_size:,}B "
468
+ f"({ibc_size/src_size*100:.0f}% of source)")
469
+ return 0
470
+
471
+
472
+ def _run_ibc(path: str) -> int:
473
+ """v2.4.0: Load and execute a pre-compiled .ibc bytecode file."""
474
+ try:
475
+ from vm_code import read_ibc
476
+ from vm import VM
477
+ proto = read_ibc(path)
478
+ except ValueError as e:
479
+ print(f"[InScript] run: {e}", file=sys.stderr)
480
+ return 1
481
+ except Exception as e:
482
+ print(f"[InScript] run: failed to load '{path}': {e}", file=sys.stderr)
483
+ return 1
484
+ try:
485
+ vm = VM(filename=path)
486
+ vm.run(proto)
487
+ return 0
488
+ except Exception as e:
489
+ print(f"[InScript] runtime error: {e}", file=sys.stderr)
490
+ return 1
491
+
492
+
493
+ def _dce_proto(proto) -> int:
494
+ """
495
+ v2.4.0: Dead-code elimination at the FnProto level.
496
+ Removes nested protos whose names are never referenced in the parent's
497
+ name table. Returns the number of protos removed.
498
+ """
499
+ if not proto.protos:
500
+ return 0
501
+ referenced = set(proto.names)
502
+ before = len(proto.protos)
503
+ # Recurse into survivors first
504
+ for sub in proto.protos:
505
+ _dce_proto(sub)
506
+ # Remove unreferenced nested protos
507
+ proto.protos = [p for p in proto.protos if p.name in referenced or p.name == "<main>"]
508
+ removed = before - len(proto.protos)
509
+ return removed
510
+
511
+
512
+ def _dce_pass(program):
513
+ """AST-level DCE (used by test_v240 for unit-testing DCE logic)."""
514
+ from ast_nodes import FunctionDecl, IdentExpr, Program
515
+
516
+ referenced: set = set()
517
+
518
+ def _walk(node):
519
+ if node is None: return
520
+ if isinstance(node, IdentExpr):
521
+ referenced.add(node.name)
522
+ return
523
+ for attr in (vars(node).values() if hasattr(node, '__dict__') else []):
524
+ if hasattr(attr, '__dict__'):
525
+ _walk(attr)
526
+ elif isinstance(attr, list):
527
+ for item in attr:
528
+ if hasattr(item, '__dict__'): _walk(item)
529
+ elif isinstance(attr, tuple):
530
+ for item in attr:
531
+ if hasattr(item, '__dict__'): _walk(item)
532
+
533
+ for stmt in program.body:
534
+ _walk(stmt)
535
+
536
+ kept = []
537
+ removed = 0
538
+ for stmt in program.body:
539
+ if isinstance(stmt, FunctionDecl) and stmt.name not in referenced:
540
+ if not getattr(stmt, 'exported', False):
541
+ removed += 1
542
+ continue
543
+ kept.append(stmt)
544
+
545
+ if removed:
546
+ print(f"[InScript] DCE: eliminated {removed} unreferenced function(s)")
547
+ return Program(body=kept)
548
+
549
+
550
+
551
+
405
552
  # ─── v1.6.0 helpers ──────────────────────────────────────────────────────────
406
553
 
407
554
  def _find_ins_files(directory: str):
@@ -2108,12 +2255,35 @@ Examples:
2108
2255
  help="Window height for --game mode (default: 600)")
2109
2256
  parser.add_argument("--fps", type=int, default=60,
2110
2257
  help="Target FPS for --game mode (default: 60)")
2258
+ # ── v2.4.0: compilation flags ────────────────────────────────────────────
2259
+ parser.add_argument("--compile", metavar="FILE",
2260
+ help="v2.4.0: AOT-compile FILE.ins → FILE.ibc bytecode")
2261
+ parser.add_argument("--output", "-o", metavar="OUT",
2262
+ help="v2.4.0: Output path for --compile (default: same dir as input)")
2263
+ parser.add_argument("--target", metavar="TARGET", default="interpreter",
2264
+ choices=["interpreter", "wasm", "native", "ibc"],
2265
+ help="v2.4.0: Compilation target: interpreter (default), ibc, wasm, native")
2266
+ parser.add_argument("--no-dce", action="store_true",
2267
+ help="v2.4.0: Disable dead-code elimination before compilation")
2268
+ parser.add_argument("--incremental", action="store_true",
2269
+ help="v2.4.0: Skip recompile if .ibc is newer than source")
2111
2270
  args = parser.parse_args()
2112
2271
 
2113
2272
  if args.version:
2114
2273
  print(f"InScript {VERSION}")
2115
2274
  return
2116
2275
 
2276
+ # ── v2.4.0: --compile ────────────────────────────────────────────────────
2277
+ if args.compile:
2278
+ import sys as _sys
2279
+ _sys.exit(_compile_cmd(
2280
+ src_path = args.compile,
2281
+ out_path = args.output,
2282
+ target = args.target,
2283
+ dce = not args.no_dce,
2284
+ incremental= args.incremental,
2285
+ ) or 0)
2286
+
2117
2287
  # ── inscript --fmt / --fmt-check / --fmt-dry-run ─────────────────────────
2118
2288
  if args.fmt or args.fmt_check or args.fmt_dry_run:
2119
2289
  try:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: inscript-lang
3
- Version: 2.2.0
3
+ Version: 2.4.0
4
4
  Summary: InScript — a game-focused scripting language with 59 game modules and a bytecode VM
5
5
  Author: Shreyasi Sarkar
6
6
  License: MIT
@@ -28,6 +28,9 @@ from errors import (
28
28
  # INTERPRETER
29
29
  # ─────────────────────────────────────────────────────────────────────────────
30
30
 
31
+ # v2.4.0: sentinel for inline cache misses (faster than None since None is valid)
32
+ _CACHE_MISS = object()
33
+
31
34
  class Interpreter(Visitor):
32
35
  """
33
36
  Tree-walking interpreter. Visits each AST node and returns a Python value.
@@ -48,6 +51,9 @@ class Interpreter(Visitor):
48
51
  self._current_fn: object = None
49
52
  # v1.4.0: defer stack — list of exprs to run at end of current function
50
53
  self._deferred: list = []
54
+ # v2.4.0: inline caches — monomorphic site caching for hot paths
55
+ self._ic_struct_fields: dict = {} # struct_name -> {field: default_idx}
56
+ self._ic_fn_lookup: dict = {} # fn_name -> InScriptFunction (global scope)
51
57
 
52
58
  self._register_builtins()
53
59
 
@@ -421,13 +427,16 @@ class Interpreter(Visitor):
421
427
  "is_bool": lambda v: isinstance(v, bool),
422
428
  "is_array": lambda v: isinstance(v, list),
423
429
  "is_dict": lambda v: isinstance(v, dict),
424
- # ── Concurrency (lightweight stubs, full impl via async/await) ─────
425
- "thread": lambda fn, *args: self._spawn_thread(fn, list(args)),
426
- "channel": lambda cap=0: {"_type":"channel","_channel":__import__("queue").SimpleQueue(),"_cap":int(cap)},
427
- "make_channel": lambda cap=0: {"_type":"channel","_channel":__import__("queue").SimpleQueue(),"_cap":int(cap)},
428
- "chan_send": lambda ch, v: ch["_channel"].put(v) if isinstance(ch,dict) and "_channel" in ch else None,
429
- "chan_recv": lambda ch: ch["_channel"].get(timeout=5) if isinstance(ch,dict) and "_channel" in ch else None,
430
- "sleep": lambda s: __import__('time').sleep(s),
430
+ # ── Concurrency v2.3.0 ───────────────────────────────────────────
431
+ "thread": lambda fn, *args: self._spawn_thread(fn, list(args)),
432
+ "channel": lambda cap=0: __import__('stdlib_values').InScriptChannel(int(cap) if cap else 0),
433
+ "make_channel": lambda cap=0: __import__('stdlib_values').InScriptChannel(int(cap) if cap else 0),
434
+ "chan_send": lambda ch, v: ch.send(v) if hasattr(ch, 'send') else (ch["_channel"].put(v) if isinstance(ch, dict) and "_channel" in ch else None),
435
+ "chan_recv": lambda ch: ch.recv() if hasattr(ch, 'recv') else (ch["_channel"].get(timeout=5) if isinstance(ch, dict) and "_channel" in ch else None),
436
+ "chan_try_recv": lambda ch: ch.try_recv() if hasattr(ch, 'try_recv') else None,
437
+ "mutex": lambda v=None: __import__('stdlib_values').InScriptMutex(v),
438
+ "rwlock": lambda v=None: __import__('stdlib_values').InScriptRWLock(v),
439
+ "sleep": lambda s: __import__('time').sleep(s),
431
440
  # ── JSON ──────────────────────────────────────────────────────────
432
441
  "json_encode": lambda v: __import__('json').dumps(v),
433
442
  "json_decode": lambda s: __import__('json').loads(s),
@@ -530,6 +539,8 @@ class Interpreter(Visitor):
530
539
  # v1.9.7: mark async functions
531
540
  fn.is_async = getattr(node, 'is_async', False)
532
541
  self._env.define(node.name, fn)
542
+ # v2.4.0: invalidate inline cache if this function was cached
543
+ self._ic_fn_lookup.pop(node.name, None)
533
544
  return fn
534
545
 
535
546
  def visit_GeneratorFnDecl(self, node) -> Any:
@@ -1080,6 +1091,30 @@ class Interpreter(Visitor):
1080
1091
  if isinstance(iterable, dict) and iterable.get("_enum_definition"):
1081
1092
  variants = [v for k, v in iterable.items() if not k.startswith("_")]
1082
1093
  iterable = variants
1094
+ # v2.3.0: async for var in channel { } — drain channel until closed
1095
+ from stdlib_values import InScriptChannel as _ISCh
1096
+ if isinstance(iterable, _ISCh) and getattr(node, '_async_iter', False):
1097
+ while not (iterable.closed and iterable._q.empty()):
1098
+ val = iterable.try_recv()
1099
+ if val is None:
1100
+ if iterable.closed:
1101
+ break
1102
+ import time as _t; _t.sleep(0.001)
1103
+ continue
1104
+ self._push("for")
1105
+ self._env.define(node.var_name, val)
1106
+ try:
1107
+ self._exec_block_no_scope(node.body)
1108
+ except BreakSignal as sig:
1109
+ self._pop()
1110
+ if sig.label: raise
1111
+ break
1112
+ except ContinueSignal as sig:
1113
+ self._pop()
1114
+ if sig.label: raise
1115
+ continue
1116
+ self._pop()
1117
+ return
1083
1118
  # Support list, range, InScriptRange, string, dict, generator
1084
1119
  if isinstance(iterable, InScriptRange):
1085
1120
  it = iter(iterable)
@@ -1547,10 +1582,18 @@ class Interpreter(Visitor):
1547
1582
  decl = None
1548
1583
  parent_decl = getattr(decl, '_resolved_parent', None)
1549
1584
  if parent_decl is not None:
1550
- # Return a SuperProxy that knows the parent and self
1551
1585
  return _SuperProxy(self_val, parent_decl, self)
1552
1586
  self._error("'super' only valid in a struct that extends another", node.line)
1553
- return self._env.get(node.name, node.line)
1587
+ # v2.4.0: inline cache — fast path for global function lookups
1588
+ name = node.name
1589
+ ic = self._ic_fn_lookup.get(name)
1590
+ if ic is not None:
1591
+ return ic
1592
+ val = self._env.get(name, node.line)
1593
+ # Populate cache for top-level InScriptFunctions (stable globals only)
1594
+ if isinstance(val, InScriptFunction) and self._env is self._globals:
1595
+ self._ic_fn_lookup[name] = val
1596
+ return val
1554
1597
 
1555
1598
  def visit_ArrayLiteralExpr(self, node: ArrayLiteralExpr) -> Any:
1556
1599
  result = []
@@ -2010,6 +2053,11 @@ class Interpreter(Visitor):
2010
2053
 
2011
2054
  def visit_GetAttrExpr(self, node: GetAttrExpr) -> Any:
2012
2055
  obj = self.visit(node.obj)
2056
+ # v2.4.0: inline cache for struct field access — skip full _get_attr for hot path
2057
+ if isinstance(obj, InScriptInstance):
2058
+ val = obj.fields.get(node.attr, _CACHE_MISS)
2059
+ if val is not _CACHE_MISS:
2060
+ return val
2013
2061
  return _get_attr(obj, node.attr, node.line, self)
2014
2062
 
2015
2063
  def visit_IndexExpr(self, node: IndexExpr) -> Any:
@@ -2524,11 +2572,10 @@ class Interpreter(Visitor):
2524
2572
 
2525
2573
  def visit_AwaitExpr(self, node: AwaitExpr) -> Any:
2526
2574
  """
2527
- v1.9.7: True async/await.
2528
- - InScriptCoroutine drive via Python coroutine protocol (sync driver).
2529
- - Plain value passthrough (backwards compatible).
2530
- - Exceptions from inside the coroutine are converted to InScriptRuntimeError
2531
- so they are catchable by InScript try/catch blocks.
2575
+ v2.3.0: await <expr>
2576
+ InScript async fns return InScriptCoroutine. We drive them to completion
2577
+ using send(None) the coroutine body runs synchronously and raises
2578
+ StopIteration(result) immediately (InScript has no real IO yields).
2532
2579
  """
2533
2580
  val = self.visit(node.expr)
2534
2581
  if isinstance(val, InScriptCoroutine):
@@ -2538,17 +2585,77 @@ class Interpreter(Visitor):
2538
2585
  except StopIteration as e:
2539
2586
  return e.value
2540
2587
  except InScriptRuntimeError:
2541
- raise # already an InScript error, let it propagate
2588
+ raise
2542
2589
  except Exception as e:
2543
- # Convert Python exceptions to InScriptRuntimeError so
2544
- # they are catchable by InScript try/catch blocks.
2545
2590
  err = InScriptRuntimeError(str(e), getattr(node, 'line', 0))
2546
2591
  err.thrown_value = str(e)
2547
2592
  raise err
2548
- return val # plain value: passthrough
2593
+ return val # plain value passthrough
2549
2594
 
2550
- def visit_SpawnExpr(self, node: SpawnExpr) -> Any:
2551
- return None # Phase 6 (ECS)
2595
+ def visit_SpawnExpr(self, node) -> Any:
2596
+ """ECS spawn handled by game backend; stub in core interpreter."""
2597
+ return None
2598
+
2599
+ def visit_TaskSpawnExpr(self, node) -> Any:
2600
+ """v2.3.0: spawn <expr> — run in background thread, return InScriptTask."""
2601
+ from stdlib_values import InScriptTask
2602
+ expr_val = self.visit(node.expr)
2603
+ if isinstance(expr_val, InScriptCoroutine):
2604
+ coro = expr_val.coro
2605
+ def _run_coro():
2606
+ try:
2607
+ coro.send(None)
2608
+ except StopIteration as e:
2609
+ return e.value
2610
+ return None
2611
+ task = InScriptTask(_run_coro, None)
2612
+ elif callable(expr_val):
2613
+ fn = expr_val
2614
+ def _run_fn():
2615
+ return self._call_fn(fn, [])
2616
+ task = InScriptTask(_run_fn, None)
2617
+ else:
2618
+ val = expr_val
2619
+ task = InScriptTask(lambda: val, None)
2620
+ return task
2621
+
2622
+ def visit_SelectStmt(self, node) -> Any:
2623
+ """v2.3.0: select { case x = ch.recv() { } case ch.send(v) { } case timeout(t) { } }"""
2624
+ from stdlib_values import InScriptChannel
2625
+ from ast_nodes import CallExpr, GetAttrExpr
2626
+ for clause in node.clauses:
2627
+ kind = clause.get("kind")
2628
+ if kind == "recv":
2629
+ # clause["channel"] is CallExpr(callee=GetAttr(ch, "recv"), args=[])
2630
+ call_expr = clause.get("channel")
2631
+ if call_expr and isinstance(call_expr, CallExpr):
2632
+ ch = self.visit(call_expr.callee.obj)
2633
+ else:
2634
+ ch = None
2635
+ if isinstance(ch, InScriptChannel):
2636
+ val = ch.try_recv()
2637
+ if val is not None:
2638
+ var = clause.get("var")
2639
+ if var:
2640
+ self._env.define(var, val)
2641
+ self.visit(clause["body"])
2642
+ return
2643
+ elif kind == "send":
2644
+ # clause["channel"] is CallExpr(callee=GetAttr(ch, "send"), args=[val])
2645
+ call_expr = clause.get("channel")
2646
+ if call_expr and isinstance(call_expr, CallExpr):
2647
+ ch = self.visit(call_expr.callee.obj)
2648
+ val = self.visit(call_expr.args[0].value) if call_expr.args else None
2649
+ else:
2650
+ ch = val = None
2651
+ if isinstance(ch, InScriptChannel) and not ch._q.full():
2652
+ ch.send(val)
2653
+ self.visit(clause["body"])
2654
+ return
2655
+ elif kind == "timeout":
2656
+ # Timeout clause fires as fallback — always last resort
2657
+ self.visit(clause["body"])
2658
+ return
2552
2659
 
2553
2660
  def visit_MatchArm(self, node: MatchArm) -> Any:
2554
2661
  return self.visit(node.body)
@@ -2869,6 +2976,38 @@ def _arr_count(lst, fn_or_val, interp):
2869
2976
 
2870
2977
 
2871
2978
  def _get_attr(obj: Any, name: str, line: int, interp: Interpreter) -> Any:
2979
+ # v2.3.0/v2.4.0: concurrency primitive attribute dispatch
2980
+ # Only import when obj is one of the concurrency types (type-name fast check)
2981
+ _tname = type(obj).__name__
2982
+ if _tname == "InScriptTask":
2983
+ import stdlib_values as _sv
2984
+ if name == "join": return lambda timeout=None: obj.join(timeout)
2985
+ if name == "done": return obj.done
2986
+ if name == "result": return obj._result
2987
+ interp._error(f"Task has no member '{name}'. Available: join, done, result", line)
2988
+ elif _tname == "InScriptChannel":
2989
+ if name == "send": return lambda v: obj.send(v)
2990
+ if name == "recv": return lambda: obj.recv()
2991
+ if name == "try_recv": return lambda: obj.try_recv()
2992
+ if name == "close": return lambda: obj.close()
2993
+ if name == "closed": return obj.closed
2994
+ if name == "size": return obj._q.qsize()
2995
+ if name == "is_empty": return obj._q.empty()
2996
+ interp._error(f"Channel has no member '{name}'. Available: send, recv, try_recv, close, closed, size", line)
2997
+ elif _tname == "InScriptMutex":
2998
+ if name == "lock": return lambda fn: obj.lock(lambda args: interp._call_fn(fn, args))
2999
+ if name == "set": return lambda v: obj.set(v)
3000
+ if name == "value": return obj.value
3001
+ interp._error(f"Mutex has no member '{name}'. Available: lock, set, value", line)
3002
+ elif _tname == "InScriptRWLock":
3003
+ if name == "read": return lambda fn: obj.read(lambda args: interp._call_fn(fn, args))
3004
+ if name == "write": return lambda fn: obj.write(lambda args: interp._call_fn(fn, args))
3005
+ if name == "value": return obj.value
3006
+ interp._error(f"RWLock has no member '{name}'. Available: read, write, value", line)
3007
+ elif _tname == "InScriptTimerHandle":
3008
+ if name == "cancel": return lambda: obj.cancel()
3009
+ interp._error(f"TimerHandle has no member '{name}'. Available: cancel", line)
3010
+
2872
3011
  # StructDecl used as namespace for static method access: M.sq(5)
2873
3012
  if isinstance(obj, StructDecl):
2874
3013
  if hasattr(obj, '_static_ns') and name in obj._static_ns:
@@ -147,6 +147,12 @@ class Parser:
147
147
 
148
148
  # --- Async/Await ---
149
149
  if tok.type == TT.ASYNC:
150
+ # v2.3.0: async for var in channel { } — iterate over async iterable
151
+ if self.peek.type == TT.FOR:
152
+ self.advance() # consume 'async'
153
+ node = self.parse_for_in()
154
+ node._async_iter = True # flag for interpreter
155
+ return node
150
156
  return self.parse_async_fn()
151
157
 
152
158
  # --- Await expr ---
@@ -1817,6 +1823,26 @@ class Parser:
1817
1823
  args = self.parse_arg_list()
1818
1824
  expr = CallExpr(callee=expr, args=args, line=line, col=col)
1819
1825
 
1826
+ # v2.3.0: generic builtin call: channel<T>(cap) / Stack<T>() etc.
1827
+ # Pattern: IDENT < TYPE_OR_IDENT > (
1828
+ elif (self.check(TT.LT)
1829
+ and isinstance(expr, IdentExpr)
1830
+ and self.peek.type in (TT.INT_TYPE, TT.FLOAT_TYPE, TT.STRING_TYPE,
1831
+ TT.BOOL_TYPE, TT.IDENT, TT.VOID_TYPE)):
1832
+ saved = self.pos
1833
+ self.advance() # consume '<'
1834
+ self.advance() # consume type name
1835
+ if self.check(TT.GT):
1836
+ self.advance() # consume '>'
1837
+ if self.check(TT.LPAREN):
1838
+ # It's a generic call — type param is stripped (runtime ignores it)
1839
+ args = self.parse_arg_list()
1840
+ expr = CallExpr(callee=expr, args=args, line=line, col=col)
1841
+ continue
1842
+ # Not a generic call — backtrack and stop postfix chain
1843
+ self.pos = saved
1844
+ break
1845
+
1820
1846
  # Optional chaining: expr?.member or expr?.method(args)
1821
1847
  elif self.check(TT.QUESTION_DOT):
1822
1848
  self.advance() # consume '?.'
@@ -2039,6 +2065,13 @@ class Parser:
2039
2065
  expr = self.parse_unary()
2040
2066
  return AwaitExpr(expr=expr, line=line, col=col)
2041
2067
 
2068
+ # v2.3.0: spawn <expr> — concurrency task
2069
+ if tok.type == TT.SPAWN:
2070
+ self.advance() # consume 'spawn'
2071
+ expr = self.parse_unary()
2072
+ from ast_nodes import TaskSpawnExpr
2073
+ return TaskSpawnExpr(expr=expr, line=line, col=col)
2074
+
2042
2075
  # try { expr } catch e { expr } — try as expression (returns value)
2043
2076
  if tok.type == TT.TRY:
2044
2077
  return self._parse_try_expr()
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "inscript-lang"
7
- version = "2.2.0"
7
+ version = "2.4.0"
8
8
  description = "InScript — a game-focused scripting language with 59 game modules and a bytecode VM"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -40,7 +40,7 @@ sys.path.insert(0, str(Path(__file__).parent))
40
40
 
41
41
  HISTORY_FILE = Path.home() / ".inscript" / "history"
42
42
  HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True)
43
- VERSION = "2.2.0"
43
+ VERSION = "2.4.0"
44
44
 
45
45
  # ── ANSI colours ──────────────────────────────────────────────────────────────
46
46
  def _c(code, text):
@@ -1063,8 +1063,79 @@ except KeyError:
1063
1063
  "sleep_sync": _timer_sleep_sync, # synchronous fallback
1064
1064
  "now": lambda: int(_time_mod.time() * 1000), # ms since epoch
1065
1065
  "now_sec": lambda: _time_mod.time(), # seconds since epoch
1066
+ # v2.3.0: after / every / cancel
1067
+ "after": None, # patched below
1068
+ "every": None, # patched below
1069
+ "cancel": None, # patched below
1066
1070
  })
1067
1071
 
1072
+ # v2.3.0: patch timer.after / timer.every / timer.cancel
1073
+ try:
1074
+ import threading as _thr_mod
1075
+ from stdlib_values import InScriptTimerHandle as _THndl
1076
+
1077
+ def _timer_after(ms, fn):
1078
+ """timer.after(ms, fn) — call fn() once after ms milliseconds."""
1079
+ def _fire():
1080
+ try: fn([])
1081
+ except Exception: pass
1082
+ t = _thr_mod.Timer(int(ms) / 1000.0, _fire)
1083
+ t.daemon = True
1084
+ t.start()
1085
+ return _THndl(t)
1086
+
1087
+ def _timer_every(ms, fn):
1088
+ """timer.every(ms, fn) — call fn() every ms milliseconds until cancelled."""
1089
+ handle = [None]
1090
+ def _tick():
1091
+ try: fn([])
1092
+ except Exception: pass
1093
+ t = _thr_mod.Timer(int(ms) / 1000.0, _tick)
1094
+ t.daemon = True
1095
+ handle[0] = t
1096
+ t.start()
1097
+ t0 = _thr_mod.Timer(int(ms) / 1000.0, _tick)
1098
+ t0.daemon = True
1099
+ handle[0] = t0
1100
+ t0.start()
1101
+ class _EveryHandle:
1102
+ def cancel(self): handle[0] and handle[0].cancel()
1103
+ def __repr__(self): return "<TimerHandle periodic>"
1104
+ return _EveryHandle()
1105
+
1106
+ def _timer_cancel(handle):
1107
+ if hasattr(handle, 'cancel'): handle.cancel()
1108
+
1109
+ _MODULES["timer"]["after"] = _timer_after
1110
+ _MODULES["timer"]["every"] = _timer_every
1111
+ _MODULES["timer"]["cancel"] = _timer_cancel
1112
+ except Exception as _te:
1113
+ pass
1114
+
1115
+ # v2.3.0: channel, mutex, rwlock as global builtins
1116
+ try:
1117
+ from stdlib_values import InScriptChannel as _ISChannel
1118
+ from stdlib_values import InScriptMutex as _ISMutex
1119
+ from stdlib_values import InScriptRWLock as _ISRWLock
1120
+
1121
+ def _make_channel(capacity=0):
1122
+ cap = capacity[0] if isinstance(capacity, list) and capacity else (capacity or 0)
1123
+ return _ISChannel(int(cap))
1124
+
1125
+ def _make_mutex(value=None):
1126
+ v = value[0] if isinstance(value, list) and value else value
1127
+ return _ISMutex(v)
1128
+
1129
+ def _make_rwlock(value=None):
1130
+ v = value[0] if isinstance(value, list) and value else value
1131
+ return _ISRWLock(v)
1132
+
1133
+ # Register as global builtins (not modules)
1134
+ # channel/mutex/rwlock are registered directly in the interpreter globals (interpreter.py)
1135
+ pass
1136
+ except Exception as _ce:
1137
+ pass
1138
+
1068
1139
 
1069
1140
 
1070
1141
  # ── Extended stdlib (Part 1): collections, datetime, fs, process, log, test, compress
@@ -326,3 +326,151 @@ class InScriptGenerator:
326
326
  except StopIteration:
327
327
  self._done = True
328
328
  return None
329
+
330
+
331
+ # ── v2.3.0: Concurrency primitives ───────────────────────────────────────────
332
+
333
+ import threading as _threading
334
+ import queue as _queue
335
+
336
+ class InScriptTask:
337
+ """
338
+ v2.3.0: Handle returned by `spawn expr`.
339
+ Runs a callable in a background thread.
340
+ """
341
+ def __init__(self, fn, args):
342
+ self._result = None
343
+ self._error = None
344
+ self._done = _threading.Event()
345
+ self._thread = _threading.Thread(target=self._run, args=(fn, args), daemon=True)
346
+ self._thread.start()
347
+
348
+ def _run(self, fn, args):
349
+ try:
350
+ self._result = fn()
351
+ except Exception as e:
352
+ self._error = e
353
+ finally:
354
+ self._done.set()
355
+
356
+ def join(self, timeout=None):
357
+ """Block until task completes. Returns result or raises."""
358
+ self._done.wait(timeout)
359
+ if self._error:
360
+ raise self._error
361
+ return self._result
362
+
363
+ @property
364
+ def done(self):
365
+ return self._done.is_set()
366
+
367
+ def __repr__(self):
368
+ return f"<Task {'done' if self.done else 'running'}>"
369
+
370
+
371
+ class InScriptChannel:
372
+ """
373
+ v2.3.0: `channel<T>(capacity)` — typed message-passing queue.
374
+ .send(v) — put value (blocks if full, capacity=0 means unbounded)
375
+ .recv() — get value (blocks until available)
376
+ .try_recv() — get value or nil (non-blocking)
377
+ .close() — mark channel closed
378
+ .closed — bool
379
+ """
380
+ def __init__(self, capacity: int = 0):
381
+ self._q = _queue.Queue(maxsize=capacity)
382
+ self._closed = False
383
+
384
+ def send(self, value):
385
+ if self._closed:
386
+ raise RuntimeError("send on closed channel")
387
+ self._q.put(value)
388
+
389
+ def recv(self):
390
+ return self._q.get()
391
+
392
+ def try_recv(self):
393
+ try:
394
+ return self._q.get_nowait()
395
+ except _queue.Empty:
396
+ return None
397
+
398
+ def close(self):
399
+ self._closed = True
400
+
401
+ @property
402
+ def closed(self):
403
+ return self._closed
404
+
405
+ def __repr__(self):
406
+ return f"<Channel size={self._q.qsize()} closed={self._closed}>"
407
+
408
+
409
+ class InScriptMutex:
410
+ """
411
+ v2.3.0: `mutex(value)` — thread-safe value wrapper.
412
+ .lock(fn) — call fn(value) under lock, returns fn's result
413
+ .value — read current value (unsafe, prefer lock())
414
+ .set(v) — set value under lock
415
+ """
416
+ def __init__(self, value):
417
+ self._value = value
418
+ self._lock = _threading.Lock()
419
+
420
+ def lock(self, fn):
421
+ with self._lock:
422
+ result = fn([self._value]) if callable(fn) else None
423
+ return result
424
+
425
+ def set(self, value):
426
+ with self._lock:
427
+ self._value = value
428
+
429
+ @property
430
+ def value(self):
431
+ return self._value
432
+
433
+ def __repr__(self):
434
+ return f"<Mutex value={self._value!r}>"
435
+
436
+
437
+ class InScriptRWLock:
438
+ """
439
+ v2.3.0: `rwlock(value)` — reader/writer lock.
440
+ .read(fn) — call fn(value) under read lock
441
+ .write(fn) — call fn(value) under write lock, fn may return new value
442
+ """
443
+ def __init__(self, value):
444
+ self._value = value
445
+ self._lock = _threading.RLock() # simplified: use reentrant lock
446
+
447
+ def read(self, fn):
448
+ with self._lock:
449
+ return fn([self._value]) if callable(fn) else self._value
450
+
451
+ def write(self, fn):
452
+ with self._lock:
453
+ result = fn([self._value]) if callable(fn) else None
454
+ if result is not None:
455
+ self._value = result
456
+ return result
457
+
458
+ @property
459
+ def value(self):
460
+ return self._value
461
+
462
+ def __repr__(self):
463
+ return f"<RWLock value={self._value!r}>"
464
+
465
+
466
+ class InScriptTimerHandle:
467
+ """Handle returned by timer.after / timer.every for cancellation."""
468
+ def __init__(self, timer_obj):
469
+ self._timer = timer_obj # threading.Timer or periodic wrapper
470
+
471
+ def cancel(self):
472
+ if hasattr(self._timer, 'cancel'):
473
+ self._timer.cancel()
474
+
475
+ def __repr__(self):
476
+ return "<TimerHandle>"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes