inscript-lang 2.1.2__tar.gz → 2.3.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.1.2 → inscript_lang-2.3.0}/PKG-INFO +1 -1
  2. {inscript_lang-2.1.2 → inscript_lang-2.3.0}/ast_nodes.py +21 -0
  3. {inscript_lang-2.1.2 → inscript_lang-2.3.0}/inscript.py +1 -1
  4. {inscript_lang-2.1.2 → inscript_lang-2.3.0}/inscript_lang.egg-info/PKG-INFO +1 -1
  5. {inscript_lang-2.1.2 → inscript_lang-2.3.0}/interpreter.py +185 -19
  6. {inscript_lang-2.1.2 → inscript_lang-2.3.0}/lexer.py +4 -1
  7. {inscript_lang-2.1.2 → inscript_lang-2.3.0}/parser.py +164 -8
  8. {inscript_lang-2.1.2 → inscript_lang-2.3.0}/pyproject.toml +1 -1
  9. {inscript_lang-2.1.2 → inscript_lang-2.3.0}/repl.py +1 -1
  10. {inscript_lang-2.1.2 → inscript_lang-2.3.0}/stdlib.py +71 -0
  11. {inscript_lang-2.1.2 → inscript_lang-2.3.0}/stdlib_values.py +148 -0
  12. {inscript_lang-2.1.2 → inscript_lang-2.3.0}/README.md +0 -0
  13. {inscript_lang-2.1.2 → inscript_lang-2.3.0}/analyzer.py +0 -0
  14. {inscript_lang-2.1.2 → inscript_lang-2.3.0}/compiler.py +0 -0
  15. {inscript_lang-2.1.2 → inscript_lang-2.3.0}/environment.py +0 -0
  16. {inscript_lang-2.1.2 → inscript_lang-2.3.0}/errors.py +0 -0
  17. {inscript_lang-2.1.2 → inscript_lang-2.3.0}/inscript_fmt.py +0 -0
  18. {inscript_lang-2.1.2 → inscript_lang-2.3.0}/inscript_lang.egg-info/SOURCES.txt +0 -0
  19. {inscript_lang-2.1.2 → inscript_lang-2.3.0}/inscript_lang.egg-info/dependency_links.txt +0 -0
  20. {inscript_lang-2.1.2 → inscript_lang-2.3.0}/inscript_lang.egg-info/entry_points.txt +0 -0
  21. {inscript_lang-2.1.2 → inscript_lang-2.3.0}/inscript_lang.egg-info/requires.txt +0 -0
  22. {inscript_lang-2.1.2 → inscript_lang-2.3.0}/inscript_lang.egg-info/top_level.txt +0 -0
  23. {inscript_lang-2.1.2 → inscript_lang-2.3.0}/inscript_test.py +0 -0
  24. {inscript_lang-2.1.2 → inscript_lang-2.3.0}/pygame_backend.py +0 -0
  25. {inscript_lang-2.1.2 → inscript_lang-2.3.0}/setup.cfg +0 -0
  26. {inscript_lang-2.1.2 → inscript_lang-2.3.0}/setup.py +0 -0
  27. {inscript_lang-2.1.2 → inscript_lang-2.3.0}/stdlib_extended.py +0 -0
  28. {inscript_lang-2.1.2 → inscript_lang-2.3.0}/stdlib_extended_2.py +0 -0
  29. {inscript_lang-2.1.2 → inscript_lang-2.3.0}/stdlib_game.py +0 -0
  30. {inscript_lang-2.1.2 → inscript_lang-2.3.0}/vm.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: inscript-lang
3
- Version: 2.1.2
3
+ Version: 2.3.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
@@ -735,3 +735,24 @@ class SelectStmt(Node):
735
735
  Each clause: {"kind": "recv"/"send"/"timeout", "var":str|None, "channel":expr|None, "value":expr|None, "duration":expr|None, "body":BlockStmt}
736
736
  """
737
737
  clauses: list # list of clause dicts
738
+
739
+ # ── v2.2.0 new nodes ─────────────────────────────────────────────────────────
740
+
741
+ @dataclass
742
+ class WithExpr(Node):
743
+ """with expr { .field = val, ... } — clone-and-modify pattern."""
744
+ obj: object # expression producing the base object
745
+ fields: list # list of (str field_name, expr value)
746
+
747
+ @dataclass
748
+ class DestructParam(Node):
749
+ """Destructuring parameter: fn f({x, y}: Point) or fn f([head, ...tail]: [])"""
750
+ kind: str # 'dict' or 'array'
751
+ names: list # list of str (field/element names to bind)
752
+ rest: object # str name for rest element (...tail), or None
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.1.2"
27
+ VERSION = "2.3.0"
28
28
 
29
29
  MANIFEST_FILENAME = "inscript.toml"
30
30
  LOCK_FILENAME = "inscript.lock"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: inscript-lang
3
- Version: 2.1.2
3
+ Version: 2.3.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
@@ -421,13 +421,16 @@ class Interpreter(Visitor):
421
421
  "is_bool": lambda v: isinstance(v, bool),
422
422
  "is_array": lambda v: isinstance(v, list),
423
423
  "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),
424
+ # ── Concurrency v2.3.0 ───────────────────────────────────────────
425
+ "thread": lambda fn, *args: self._spawn_thread(fn, list(args)),
426
+ "channel": lambda cap=0: __import__('stdlib_values').InScriptChannel(int(cap) if cap else 0),
427
+ "make_channel": lambda cap=0: __import__('stdlib_values').InScriptChannel(int(cap) if cap else 0),
428
+ "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),
429
+ "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),
430
+ "chan_try_recv": lambda ch: ch.try_recv() if hasattr(ch, 'try_recv') else None,
431
+ "mutex": lambda v=None: __import__('stdlib_values').InScriptMutex(v),
432
+ "rwlock": lambda v=None: __import__('stdlib_values').InScriptRWLock(v),
433
+ "sleep": lambda s: __import__('time').sleep(s),
431
434
  # ── JSON ──────────────────────────────────────────────────────────
432
435
  "json_encode": lambda v: __import__('json').dumps(v),
433
436
  "json_decode": lambda s: __import__('json').loads(s),
@@ -1080,6 +1083,30 @@ class Interpreter(Visitor):
1080
1083
  if isinstance(iterable, dict) and iterable.get("_enum_definition"):
1081
1084
  variants = [v for k, v in iterable.items() if not k.startswith("_")]
1082
1085
  iterable = variants
1086
+ # v2.3.0: async for var in channel { } — drain channel until closed
1087
+ from stdlib_values import InScriptChannel as _ISCh
1088
+ if isinstance(iterable, _ISCh) and getattr(node, '_async_iter', False):
1089
+ while not (iterable.closed and iterable._q.empty()):
1090
+ val = iterable.try_recv()
1091
+ if val is None:
1092
+ if iterable.closed:
1093
+ break
1094
+ import time as _t; _t.sleep(0.001)
1095
+ continue
1096
+ self._push("for")
1097
+ self._env.define(node.var_name, val)
1098
+ try:
1099
+ self._exec_block_no_scope(node.body)
1100
+ except BreakSignal as sig:
1101
+ self._pop()
1102
+ if sig.label: raise
1103
+ break
1104
+ except ContinueSignal as sig:
1105
+ self._pop()
1106
+ if sig.label: raise
1107
+ continue
1108
+ self._pop()
1109
+ return
1083
1110
  # Support list, range, InScriptRange, string, dict, generator
1084
1111
  if isinstance(iterable, InScriptRange):
1085
1112
  it = iter(iterable)
@@ -1950,7 +1977,13 @@ class Interpreter(Visitor):
1950
1977
  op = node.op
1951
1978
  target = node.target
1952
1979
 
1953
- if op != "=":
1980
+ if op == "??=":
1981
+ # v2.2.0: null-coalescing assignment — only assign if current value is nil
1982
+ cur = self.visit(target)
1983
+ if cur is not None:
1984
+ return cur # already non-nil, do nothing
1985
+ # fall through to write val
1986
+ elif op != "=":
1954
1987
  # Compound assignment: strip '=' to get operator ("+=" → "+", "**=" → "**")
1955
1988
  cur = self.visit(target)
1956
1989
  sym_op = op.rstrip("=")
@@ -2314,6 +2347,22 @@ class Interpreter(Visitor):
2314
2347
  if getattr(param, 'is_variadic', False):
2315
2348
  env.define(param.name, avs[i:]); break
2316
2349
  val = avs[i] if i < len(avs) else (self.visit(param.default) if param.default else None)
2350
+ # v2.2.0: DestructParam — dict or array destructuring
2351
+ from ast_nodes import DestructParam as _DP
2352
+ if isinstance(param, _DP):
2353
+ if param.kind == 'dict':
2354
+ src = val if isinstance(val, dict) else (val.__dict__ if hasattr(val, '__dict__') else {})
2355
+ for fname in param.names:
2356
+ env.define(fname, src.get(fname))
2357
+ if param.rest:
2358
+ env.define(param.rest, {k: v for k, v in src.items() if k not in param.names})
2359
+ else: # array
2360
+ src = list(val) if val is not None else []
2361
+ for j, fname in enumerate(param.names):
2362
+ env.define(fname, src[j] if j < len(src) else None)
2363
+ if param.rest:
2364
+ env.define(param.rest, src[len(param.names):])
2365
+ continue
2317
2366
  if param.type_ann and val is not None:
2318
2367
  ann_name = param.type_ann.name
2319
2368
  if ann_name in generic_bindings:
@@ -2502,11 +2551,10 @@ class Interpreter(Visitor):
2502
2551
 
2503
2552
  def visit_AwaitExpr(self, node: AwaitExpr) -> Any:
2504
2553
  """
2505
- v1.9.7: True async/await.
2506
- - InScriptCoroutine drive via Python coroutine protocol (sync driver).
2507
- - Plain value passthrough (backwards compatible).
2508
- - Exceptions from inside the coroutine are converted to InScriptRuntimeError
2509
- so they are catchable by InScript try/catch blocks.
2554
+ v2.3.0: await <expr>
2555
+ InScript async fns return InScriptCoroutine. We drive them to completion
2556
+ using send(None) the coroutine body runs synchronously and raises
2557
+ StopIteration(result) immediately (InScript has no real IO yields).
2510
2558
  """
2511
2559
  val = self.visit(node.expr)
2512
2560
  if isinstance(val, InScriptCoroutine):
@@ -2516,17 +2564,77 @@ class Interpreter(Visitor):
2516
2564
  except StopIteration as e:
2517
2565
  return e.value
2518
2566
  except InScriptRuntimeError:
2519
- raise # already an InScript error, let it propagate
2567
+ raise
2520
2568
  except Exception as e:
2521
- # Convert Python exceptions to InScriptRuntimeError so
2522
- # they are catchable by InScript try/catch blocks.
2523
2569
  err = InScriptRuntimeError(str(e), getattr(node, 'line', 0))
2524
2570
  err.thrown_value = str(e)
2525
2571
  raise err
2526
- return val # plain value: passthrough
2572
+ return val # plain value passthrough
2527
2573
 
2528
- def visit_SpawnExpr(self, node: SpawnExpr) -> Any:
2529
- return None # Phase 6 (ECS)
2574
+ def visit_SpawnExpr(self, node) -> Any:
2575
+ """ECS spawn handled by game backend; stub in core interpreter."""
2576
+ return None
2577
+
2578
+ def visit_TaskSpawnExpr(self, node) -> Any:
2579
+ """v2.3.0: spawn <expr> — run in background thread, return InScriptTask."""
2580
+ from stdlib_values import InScriptTask
2581
+ expr_val = self.visit(node.expr)
2582
+ if isinstance(expr_val, InScriptCoroutine):
2583
+ coro = expr_val.coro
2584
+ def _run_coro():
2585
+ try:
2586
+ coro.send(None)
2587
+ except StopIteration as e:
2588
+ return e.value
2589
+ return None
2590
+ task = InScriptTask(_run_coro, None)
2591
+ elif callable(expr_val):
2592
+ fn = expr_val
2593
+ def _run_fn():
2594
+ return self._call_fn(fn, [])
2595
+ task = InScriptTask(_run_fn, None)
2596
+ else:
2597
+ val = expr_val
2598
+ task = InScriptTask(lambda: val, None)
2599
+ return task
2600
+
2601
+ def visit_SelectStmt(self, node) -> Any:
2602
+ """v2.3.0: select { case x = ch.recv() { } case ch.send(v) { } case timeout(t) { } }"""
2603
+ from stdlib_values import InScriptChannel
2604
+ from ast_nodes import CallExpr, GetAttrExpr
2605
+ for clause in node.clauses:
2606
+ kind = clause.get("kind")
2607
+ if kind == "recv":
2608
+ # clause["channel"] is CallExpr(callee=GetAttr(ch, "recv"), args=[])
2609
+ call_expr = clause.get("channel")
2610
+ if call_expr and isinstance(call_expr, CallExpr):
2611
+ ch = self.visit(call_expr.callee.obj)
2612
+ else:
2613
+ ch = None
2614
+ if isinstance(ch, InScriptChannel):
2615
+ val = ch.try_recv()
2616
+ if val is not None:
2617
+ var = clause.get("var")
2618
+ if var:
2619
+ self._env.define(var, val)
2620
+ self.visit(clause["body"])
2621
+ return
2622
+ elif kind == "send":
2623
+ # clause["channel"] is CallExpr(callee=GetAttr(ch, "send"), args=[val])
2624
+ call_expr = clause.get("channel")
2625
+ if call_expr and isinstance(call_expr, CallExpr):
2626
+ ch = self.visit(call_expr.callee.obj)
2627
+ val = self.visit(call_expr.args[0].value) if call_expr.args else None
2628
+ else:
2629
+ ch = val = None
2630
+ if isinstance(ch, InScriptChannel) and not ch._q.full():
2631
+ ch.send(val)
2632
+ self.visit(clause["body"])
2633
+ return
2634
+ elif kind == "timeout":
2635
+ # Timeout clause fires as fallback — always last resort
2636
+ self.visit(clause["body"])
2637
+ return
2530
2638
 
2531
2639
  def visit_MatchArm(self, node: MatchArm) -> Any:
2532
2640
  return self.visit(node.body)
@@ -2677,6 +2785,29 @@ class Interpreter(Visitor):
2677
2785
  val = self.visit(node.left)
2678
2786
  return self.visit(node.right) if val is None else val
2679
2787
 
2788
+ def visit_WithExpr(self, node) -> Any:
2789
+ """v2.2.0: with obj { .field = val, ... } — clone and modify."""
2790
+ from stdlib_values import InScriptInstance
2791
+ base = self.visit(node.obj)
2792
+ if isinstance(base, InScriptInstance):
2793
+ clone = InScriptInstance(
2794
+ struct_name=base.struct_name,
2795
+ fields=dict(base.fields),
2796
+ )
2797
+ elif isinstance(base, dict):
2798
+ clone = dict(base)
2799
+ else:
2800
+ import copy; clone = copy.copy(base)
2801
+ for field_name, val_expr in node.fields:
2802
+ val = self.visit(val_expr)
2803
+ if isinstance(clone, InScriptInstance):
2804
+ clone.fields[field_name] = val
2805
+ elif isinstance(clone, dict):
2806
+ clone[field_name] = val
2807
+ else:
2808
+ _set_attr(clone, field_name, val, node.line, self)
2809
+ return clone
2810
+
2680
2811
  def visit_PipeExpr(self, node: PipeExpr) -> Any:
2681
2812
  """value |> fn — calls fn(value)."""
2682
2813
  val = self.visit(node.value)
@@ -2824,6 +2955,41 @@ def _arr_count(lst, fn_or_val, interp):
2824
2955
 
2825
2956
 
2826
2957
  def _get_attr(obj: Any, name: str, line: int, interp: Interpreter) -> Any:
2958
+ # v2.3.0: concurrency primitive attribute dispatch
2959
+ from stdlib_values import InScriptTask, InScriptChannel, InScriptMutex, InScriptRWLock, InScriptTimerHandle
2960
+
2961
+ if isinstance(obj, InScriptTask):
2962
+ if name == "join": return lambda timeout=None: obj.join(timeout)
2963
+ if name == "done": return obj.done
2964
+ if name == "result": return obj._result
2965
+ interp._error(f"Task has no member '{name}'. Available: join, done, result", line)
2966
+
2967
+ if isinstance(obj, InScriptChannel):
2968
+ if name == "send": return lambda v: obj.send(v)
2969
+ if name == "recv": return lambda: obj.recv()
2970
+ if name == "try_recv": return lambda: obj.try_recv()
2971
+ if name == "close": return lambda: obj.close()
2972
+ if name == "closed": return obj.closed
2973
+ if name == "size": return obj._q.qsize()
2974
+ if name == "is_empty": return obj._q.empty()
2975
+ interp._error(f"Channel has no member '{name}'. Available: send, recv, try_recv, close, closed, size", line)
2976
+
2977
+ if isinstance(obj, InScriptMutex):
2978
+ if name == "lock": return lambda fn: obj.lock(lambda args: interp._call_fn(fn, args))
2979
+ if name == "set": return lambda v: obj.set(v)
2980
+ if name == "value": return obj.value
2981
+ interp._error(f"Mutex has no member '{name}'. Available: lock, set, value", line)
2982
+
2983
+ if isinstance(obj, InScriptRWLock):
2984
+ if name == "read": return lambda fn: obj.read(lambda args: interp._call_fn(fn, args))
2985
+ if name == "write": return lambda fn: obj.write(lambda args: interp._call_fn(fn, args))
2986
+ if name == "value": return obj.value
2987
+ interp._error(f"RWLock has no member '{name}'. Available: read, write, value", line)
2988
+
2989
+ if isinstance(obj, InScriptTimerHandle):
2990
+ if name == "cancel": return lambda: obj.cancel()
2991
+ interp._error(f"TimerHandle has no member '{name}'. Available: cancel", line)
2992
+
2827
2993
  # StructDecl used as namespace for static method access: M.sq(5)
2828
2994
  if isinstance(obj, StructDecl):
2829
2995
  if hasattr(obj, '_static_ns') and name in obj._static_ns:
@@ -126,6 +126,7 @@ class TT(Enum):
126
126
  HASH = auto() # # (attribute / annotation)
127
127
  AT = auto() # @ (decorator)
128
128
  NULLISH = auto() # ?? (nullish coalescing)
129
+ NULLISH_EQ = auto() # ??= (null-coalescing assignment) v2.2.0
129
130
  QUESTION_DOT= auto() # ?. (optional chaining)
130
131
  PIPE_GT = auto() # |> (pipe operator)
131
132
  FSTRING = auto() # f"..." (interpolated string — value is raw template)
@@ -574,7 +575,9 @@ class Lexer:
574
575
  if self.match(":"): emit(TT.DOUBLE_COLON, "::")
575
576
  else: emit(TT.COLON, ":")
576
577
  elif ch == "?":
577
- if self.match("?"): emit(TT.NULLISH, "??")
578
+ if self.match("?"):
579
+ if self.match("="): emit(TT.NULLISH_EQ, "??=") # v2.2.0
580
+ else: emit(TT.NULLISH, "??")
578
581
  elif self.match("."): emit(TT.QUESTION_DOT, "?.")
579
582
  else: emit(TT.QUESTION, "?")
580
583
  elif ch == "#":
@@ -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 ---
@@ -402,8 +408,24 @@ class Parser:
402
408
  line, col = self._pos()
403
409
 
404
410
  # Tuple type: (int, float) — used for multiple return values
411
+ # v2.2.0: named return type (min: float, max: float)
405
412
  if self.check(TT.LPAREN):
406
413
  self.advance() # consume '('
414
+ # Detect named tuple: (IDENT COLON ...)
415
+ if (self.current.type == TT.IDENT
416
+ and self.peek.type == TT.COLON):
417
+ named_fields = []
418
+ while True:
419
+ fname = self.expect_ident("Expected field name in named return type")
420
+ self.expect(TT.COLON, "Expected ':' after field name")
421
+ ftype = self.parse_type_annotation()
422
+ named_fields.append((fname, ftype))
423
+ if not self.match(TT.COMMA):
424
+ break
425
+ self.expect(TT.RPAREN, "Expected ')' to close named return type")
426
+ ann = TypeAnnotation(name="NamedTuple", line=line, col=col)
427
+ ann.named_fields = named_fields
428
+ return ann
407
429
  # Just consume the tuple type contents and treat it as "Tuple"
408
430
  depth = 1
409
431
  while not self.is_at_end() and depth > 0:
@@ -416,12 +438,16 @@ class Parser:
416
438
  self.advance() # consume final ')'
417
439
  return TypeAnnotation(name="Tuple", line=line, col=col)
418
440
 
419
- # Array type: [int]
441
+ # Array type: [int] or bare [] (any-element array)
420
442
  if self.match(TT.LBRACKET):
421
- inner = self.parse_type_annotation()
422
- self.expect(TT.RBRACKET, "Expected ']' to close array type")
423
- ann = TypeAnnotation(name="Array", is_array=True,
424
- generics=[inner], line=line, col=col)
443
+ if self.check(TT.RBRACKET):
444
+ self.advance() # consume ']' bare [] means Array<any>
445
+ ann = TypeAnnotation(name="Array", is_array=True, line=line, col=col)
446
+ else:
447
+ inner = self.parse_type_annotation()
448
+ self.expect(TT.RBRACKET, "Expected ']' to close array type")
449
+ ann = TypeAnnotation(name="Array", is_array=True,
450
+ generics=[inner], line=line, col=col)
425
451
  # Dict type: {string: int}
426
452
  elif self.match(TT.LBRACE):
427
453
  key = self.parse_type_annotation()
@@ -647,6 +673,47 @@ class Parser:
647
673
  elif self.check(TT.ELLIPSIS):
648
674
  self.advance() # consume '...' (alias for variadic)
649
675
  is_variadic = True
676
+
677
+ # v2.2.0: dict destructuring param — fn f({x, y}: Point)
678
+ if self.check(TT.LBRACE):
679
+ self.advance() # consume '{'
680
+ names = []
681
+ rest = None
682
+ while not self.check(TT.RBRACE) and not self.check(TT.EOF):
683
+ if self.check(TT.ELLIPSIS):
684
+ self.advance()
685
+ rest = self.expect_ident("Expected rest name after '...'")
686
+ break
687
+ names.append(self.expect_ident("Expected field name"))
688
+ self.match(TT.COMMA)
689
+ self.expect(TT.RBRACE, "Expected '}' to close destructuring param")
690
+ type_ann = None
691
+ if self.match(TT.COLON):
692
+ type_ann = self.parse_type_annotation()
693
+ from ast_nodes import DestructParam
694
+ return DestructParam(kind='dict', names=names, rest=rest,
695
+ type_ann=type_ann, line=line, col=col)
696
+
697
+ # v2.2.0: array destructuring param — fn f([head, ...tail]: [])
698
+ if self.check(TT.LBRACKET):
699
+ self.advance() # consume '['
700
+ names = []
701
+ rest = None
702
+ while not self.check(TT.RBRACKET) and not self.check(TT.EOF):
703
+ if self.check(TT.ELLIPSIS):
704
+ self.advance()
705
+ rest = self.expect_ident("Expected rest name after '...'")
706
+ break
707
+ names.append(self.expect_ident("Expected element binding"))
708
+ self.match(TT.COMMA)
709
+ self.expect(TT.RBRACKET, "Expected ']' to close destructuring param")
710
+ type_ann = None
711
+ if self.match(TT.COLON):
712
+ type_ann = self.parse_type_annotation()
713
+ from ast_nodes import DestructParam
714
+ return DestructParam(kind='array', names=names, rest=rest,
715
+ type_ann=type_ann, line=line, col=col)
716
+
650
717
  name = self.expect_ident("Expected parameter name")
651
718
  type_ann = None
652
719
  if self.match(TT.COLON):
@@ -1418,6 +1485,7 @@ class Parser:
1418
1485
  TT.CARET_EQ: "^=",
1419
1486
  TT.LSHIFT_EQ: "<<=",
1420
1487
  TT.RSHIFT_EQ: ">>=",
1488
+ TT.NULLISH_EQ: "??=", # v2.2.0
1421
1489
  }
1422
1490
 
1423
1491
  if self.current.type in ASSIGN_OPS:
@@ -1427,7 +1495,8 @@ class Parser:
1427
1495
 
1428
1496
  # Extract the binary operator from compound ops (strip trailing =)
1429
1497
  # e.g. "+=" → "+", "**=" → "**"
1430
- binop = op.rstrip("=") if op != "=" else None
1498
+ # "??=" is special — not a binary op, handled by interpreter directly
1499
+ binop = op.rstrip("=") if op not in ("=", "??=") else None
1431
1500
 
1432
1501
  # Determine which assignment node to create
1433
1502
  if isinstance(expr, IdentExpr):
@@ -1594,8 +1663,32 @@ class Parser:
1594
1663
  while self.check(TT.LT, TT.GT, TT.LTE, TT.GTE):
1595
1664
  op = self.advance().value
1596
1665
  right = self.parse_shift()
1597
- left = BinaryExpr(left=left, op=op, right=right,
1598
- line=line, col=col)
1666
+ node = BinaryExpr(left=left, op=op, right=right,
1667
+ line=line, col=col)
1668
+ # v2.2.0: chained comparisons — 0 < x < 10 → (0<x) and (x<10)
1669
+ # If another comparison operator follows, desugar into `and`
1670
+ if self.check(TT.LT, TT.GT, TT.LTE, TT.GTE):
1671
+ left = node # left side of `and` is current comparison
1672
+ # `right` becomes the new LHS for the next comparison
1673
+ # We need to avoid evaluating `right` twice, but in a simple
1674
+ # interpreter it's safe to reuse the same AST node (no side effects
1675
+ # on simple expressions). Build: node AND (right op next)
1676
+ op2 = self.advance().value
1677
+ right2 = self.parse_shift()
1678
+ rhs = BinaryExpr(left=right, op=op2, right=right2,
1679
+ line=line, col=col)
1680
+ left = BinaryExpr(left=node, op="&&", right=rhs,
1681
+ line=line, col=col)
1682
+ while self.check(TT.LT, TT.GT, TT.LTE, TT.GTE):
1683
+ op3 = self.advance().value
1684
+ right3 = self.parse_shift()
1685
+ rhs3 = BinaryExpr(left=right2, op=op3, right=right3,
1686
+ line=line, col=col)
1687
+ left = BinaryExpr(left=left, op="&&", right=rhs3,
1688
+ line=line, col=col)
1689
+ right2 = right3
1690
+ return left
1691
+ left = node
1599
1692
  return left
1600
1693
 
1601
1694
  def parse_shift(self) -> Node:
@@ -1730,6 +1823,26 @@ class Parser:
1730
1823
  args = self.parse_arg_list()
1731
1824
  expr = CallExpr(callee=expr, args=args, line=line, col=col)
1732
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
+
1733
1846
  # Optional chaining: expr?.member or expr?.method(args)
1734
1847
  elif self.check(TT.QUESTION_DOT):
1735
1848
  self.advance() # consume '?.'
@@ -1879,6 +1992,25 @@ class Parser:
1879
1992
  # Grouped expression: (expr) OR Tuple: (expr, expr, ...)
1880
1993
  if tok.type == TT.LPAREN:
1881
1994
  self.advance() # consume '('
1995
+ # v2.2.0: named return tuple literal — (name: expr, name: expr, ...)
1996
+ # Detect: IDENT COLON pattern at the start
1997
+ if (self.current.type == TT.IDENT
1998
+ and self.peek.type == TT.COLON):
1999
+ pairs = []
2000
+ while True:
2001
+ key = self.expect_ident("Expected field name in named tuple")
2002
+ self.expect(TT.COLON, "Expected ':' after field name")
2003
+ val = self.parse_expr()
2004
+ pairs.append((key, val))
2005
+ if not self.match(TT.COMMA):
2006
+ break
2007
+ self.expect(TT.RPAREN, "Expected ')' to close named tuple")
2008
+ # Return as a DictLiteralExpr with string-key pairs
2009
+ return DictLiteralExpr(
2010
+ pairs=[(StringLiteralExpr(value=k, line=line, col=col), v)
2011
+ for k, v in pairs],
2012
+ line=line, col=col
2013
+ )
1882
2014
  first = self.parse_expr()
1883
2015
  if self.check(TT.COMMA):
1884
2016
  # It's a tuple literal
@@ -1933,6 +2065,13 @@ class Parser:
1933
2065
  expr = self.parse_unary()
1934
2066
  return AwaitExpr(expr=expr, line=line, col=col)
1935
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
+
1936
2075
  # try { expr } catch e { expr } — try as expression (returns value)
1937
2076
  if tok.type == TT.TRY:
1938
2077
  return self._parse_try_expr()
@@ -1948,6 +2087,23 @@ class Parser:
1948
2087
  self.advance() # consume 'comptime'
1949
2088
  body = self.parse_block()
1950
2089
  return ComptimeExpr(body=body, line=line, col=col)
2090
+ # v2.2.0: with expr { .field = val, ... } — clone-and-modify
2091
+ if tok.value == "with":
2092
+ self.advance() # consume 'with'
2093
+ obj = self.parse_postfix()
2094
+ self.expect(TT.LBRACE, "Expected '{' after 'with <expr>'")
2095
+ fields = []
2096
+ while not self.check(TT.RBRACE) and not self.check(TT.EOF):
2097
+ self.expect(TT.DOT, "Expected '.field = val' inside with block")
2098
+ field_name = self.expect_ident("Expected field name after '.'")
2099
+ self.expect(TT.ASSIGN, "Expected '=' after field name in with block")
2100
+ val = self.parse_expr()
2101
+ fields.append((field_name, val))
2102
+ self.match(TT.COMMA)
2103
+ self.match(TT.NEWLINE) if hasattr(TT, 'NEWLINE') else None
2104
+ self.expect(TT.RBRACE, "Expected '}' to close with block")
2105
+ from ast_nodes import WithExpr
2106
+ return WithExpr(obj=obj, fields=fields, line=line, col=col)
1951
2107
  return self.parse_ident_or_struct_init()
1952
2108
 
1953
2109
  # Type keywords used as constructors: Vec2(…) → these lex as IDENT anyway
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "inscript-lang"
7
- version = "2.1.2"
7
+ version = "2.3.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.1.2"
43
+ VERSION = "2.3.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