inscript-lang 1.6.0__tar.gz → 1.7.3__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-1.6.0 → inscript_lang-1.7.3}/PKG-INFO +1 -1
  2. {inscript_lang-1.6.0 → inscript_lang-1.7.3}/errors.py +39 -17
  3. {inscript_lang-1.6.0 → inscript_lang-1.7.3}/inscript.py +50 -1
  4. {inscript_lang-1.6.0 → inscript_lang-1.7.3}/inscript_lang.egg-info/PKG-INFO +1 -1
  5. {inscript_lang-1.6.0 → inscript_lang-1.7.3}/interpreter.py +74 -17
  6. {inscript_lang-1.6.0 → inscript_lang-1.7.3}/lexer.py +25 -13
  7. {inscript_lang-1.6.0 → inscript_lang-1.7.3}/parser.py +18 -7
  8. {inscript_lang-1.6.0 → inscript_lang-1.7.3}/pyproject.toml +1 -1
  9. {inscript_lang-1.6.0 → inscript_lang-1.7.3}/repl.py +14 -1
  10. {inscript_lang-1.6.0 → inscript_lang-1.7.3}/setup.py +1 -1
  11. {inscript_lang-1.6.0 → inscript_lang-1.7.3}/README.md +0 -0
  12. {inscript_lang-1.6.0 → inscript_lang-1.7.3}/analyzer.py +0 -0
  13. {inscript_lang-1.6.0 → inscript_lang-1.7.3}/ast_nodes.py +0 -0
  14. {inscript_lang-1.6.0 → inscript_lang-1.7.3}/compiler.py +0 -0
  15. {inscript_lang-1.6.0 → inscript_lang-1.7.3}/environment.py +0 -0
  16. {inscript_lang-1.6.0 → inscript_lang-1.7.3}/inscript_fmt.py +0 -0
  17. {inscript_lang-1.6.0 → inscript_lang-1.7.3}/inscript_lang.egg-info/SOURCES.txt +0 -0
  18. {inscript_lang-1.6.0 → inscript_lang-1.7.3}/inscript_lang.egg-info/dependency_links.txt +0 -0
  19. {inscript_lang-1.6.0 → inscript_lang-1.7.3}/inscript_lang.egg-info/entry_points.txt +0 -0
  20. {inscript_lang-1.6.0 → inscript_lang-1.7.3}/inscript_lang.egg-info/requires.txt +0 -0
  21. {inscript_lang-1.6.0 → inscript_lang-1.7.3}/inscript_lang.egg-info/top_level.txt +0 -0
  22. {inscript_lang-1.6.0 → inscript_lang-1.7.3}/inscript_test.py +0 -0
  23. {inscript_lang-1.6.0 → inscript_lang-1.7.3}/pygame_backend.py +0 -0
  24. {inscript_lang-1.6.0 → inscript_lang-1.7.3}/setup.cfg +0 -0
  25. {inscript_lang-1.6.0 → inscript_lang-1.7.3}/stdlib.py +0 -0
  26. {inscript_lang-1.6.0 → inscript_lang-1.7.3}/stdlib_extended.py +0 -0
  27. {inscript_lang-1.6.0 → inscript_lang-1.7.3}/stdlib_extended_2.py +0 -0
  28. {inscript_lang-1.6.0 → inscript_lang-1.7.3}/stdlib_game.py +0 -0
  29. {inscript_lang-1.6.0 → inscript_lang-1.7.3}/stdlib_values.py +0 -0
  30. {inscript_lang-1.6.0 → inscript_lang-1.7.3}/vm.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: inscript-lang
3
- Version: 1.6.0
3
+ Version: 1.7.3
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
@@ -53,6 +53,9 @@ ERROR_CODES = {
53
53
  "MatchError": "E0047",
54
54
  "PropertyError": "E0048",
55
55
  "NilAccess": "E0049",
56
+
57
+ # Deprecated-keyword hard errors (v1.7.4)
58
+ "NullKeyword": "E0055",
56
59
  }
57
60
 
58
61
  DOCS_BASE = "https://docs.inscript.dev/errors"
@@ -103,11 +106,15 @@ class InScriptError(Exception):
103
106
  if self.hint:
104
107
  parts.append(f" Hint: {self.hint}")
105
108
 
106
- # Call trace (runtime only)
109
+ # Call trace (runtime only) — v1.7.3: entries are (fn, file, line, src) 4-tuples
107
110
  if self.call_trace:
108
111
  parts.append("\nCall stack (most recent last):")
109
- for fn, file, ln in self.call_trace:
112
+ for entry in self.call_trace:
113
+ fn, file, ln = entry[0], entry[1], entry[2]
114
+ src = entry[3] if len(entry) > 3 else ""
110
115
  parts.append(f" File \"{file}\", line {ln}, in {fn}")
116
+ if src:
117
+ parts.append(f" {src}")
111
118
 
112
119
  # Docs link
113
120
  parts.append(f" See: {DOCS_BASE}/{code}")
@@ -222,11 +229,12 @@ class InScriptWarning:
222
229
  # ─────────────────────────────────────────────────────────────────────────────
223
230
 
224
231
  class CallFrame:
225
- __slots__ = ("fn_name", "file", "line")
226
- def __init__(self, fn_name: str, file: str, line: int):
227
- self.fn_name = fn_name
228
- self.file = file
229
- self.line = line
232
+ __slots__ = ("fn_name", "file", "line", "source_line")
233
+ def __init__(self, fn_name: str, file: str, line: int, source_line: str = ""):
234
+ self.fn_name = fn_name
235
+ self.file = file
236
+ self.line = line
237
+ self.source_line = source_line # v1.7.3: source snippet at this frame
230
238
 
231
239
  def as_tuple(self) -> Tuple[str, str, int]:
232
240
  return (self.fn_name, self.file, self.line)
@@ -239,40 +247,54 @@ class InScriptCallStack:
239
247
  """
240
248
  MAX_FRAMES = 200
241
249
 
242
- def __init__(self, filename: str = "<script>"):
250
+ def __init__(self, filename: str = "<script>", src_lines: list = None):
243
251
  self._frames: List[CallFrame] = []
244
252
  self._file = filename
253
+ self._src = src_lines or [] # v1.7.3: source lines for per-frame snippets
254
+
255
+ def _lookup_src(self, line: int) -> str:
256
+ """Return stripped source line, or '' if unavailable."""
257
+ if self._src and 0 < line <= len(self._src):
258
+ return self._src[line - 1].strip()
259
+ return ""
245
260
 
246
261
  def push(self, fn_name: str, line: int):
247
262
  if len(self._frames) < self.MAX_FRAMES:
248
- self._frames.append(CallFrame(fn_name, self._file, line))
263
+ self._frames.append(
264
+ CallFrame(fn_name, self._file, line, self._lookup_src(line))
265
+ )
249
266
 
250
267
  def pop(self):
251
268
  if self._frames:
252
269
  self._frames.pop()
253
270
 
254
271
  def snapshot(self) -> List[Tuple[str, str, int]]:
255
- """Return a copy of current frames as list of (fn, file, line) tuples."""
256
- return [f.as_tuple() for f in self._frames]
272
+ """Return a copy of current frames as (fn, file, line, source_line) 4-tuples.
273
+ Backwards-compatible: consumers that only unpack 3 elements still work."""
274
+ return [(f.fn_name, f.file, f.line, f.source_line) for f in self._frames]
257
275
 
258
276
  def update_top_line(self, line: int):
259
- """Keep top frame's line number current as execution proceeds."""
260
- if self._frames:
261
- self._frames[-1].line = line
277
+ """v1.7.3: Keep top frame's line number AND source snippet current during execution."""
278
+ if self._frames and line:
279
+ f = self._frames[-1]
280
+ f.line = line
281
+ f.source_line = self._lookup_src(line)
262
282
 
263
283
  def format(self) -> str:
264
284
  if not self._frames:
265
285
  return ""
266
- lines = ["Call stack (most recent last):"]
267
- shown = self._frames
286
+ lines = ["Call stack (most recent last):"]
287
+ shown = self._frames
268
288
  truncated = 0
269
289
  if len(shown) > 20:
270
290
  truncated = len(shown) - 20
271
- shown = shown[-20:]
291
+ shown = shown[-20:]
272
292
  if truncated:
273
293
  lines.append(f" ... {truncated} earlier frame(s) ...")
274
294
  for f in shown:
275
295
  lines.append(f' File "{f.file}", line {f.line}, in {f.fn_name}')
296
+ if f.source_line: # v1.7.3: source snippet per frame
297
+ lines.append(f" {f.source_line}")
276
298
  return "\n".join(lines)
277
299
 
278
300
 
@@ -24,7 +24,7 @@ from errors import (InScriptError, LexerError, ParseError,
24
24
  SemanticError, InScriptRuntimeError,
25
25
  MultiError, InScriptWarning)
26
26
 
27
- VERSION = "1.6.0"
27
+ VERSION = "1.7.3"
28
28
  LANG = "InScript"
29
29
  PACKAGES_DIR = os.path.join(os.path.expanduser("~"), ".inscript", "packages")
30
30
  REGISTRY_URL = "https://raw.githubusercontent.com/authorss81/inscript-packages/main/registry.json"
@@ -245,6 +245,51 @@ def _check_all_files(directory: str, strict: bool = False) -> int:
245
245
  return 1 if errors else 0
246
246
 
247
247
 
248
+ def _migrate_files(path: str) -> int:
249
+ """v1.7.4: Auto-migrate deprecated InScript syntax in-place."""
250
+ import re, os
251
+ target = os.path.abspath(path)
252
+ files = []
253
+ if os.path.isfile(target):
254
+ files = [target]
255
+ elif os.path.isdir(target):
256
+ files = list(_find_ins_files(target))
257
+ else:
258
+ print(f"[InScript migrate] Not found: '{path}'", file=sys.stderr)
259
+ return 1
260
+
261
+ changed = errors = 0
262
+ for fpath in files:
263
+ try:
264
+ with open(fpath, encoding="utf-8") as f:
265
+ original = f.read()
266
+ src = original
267
+ # null → nil
268
+ src = re.sub(r'\bnull\b', 'nil', src)
269
+ # x div y → x // y (only bare `div` between expressions)
270
+ src = re.sub(r'\bdiv\b', '//', src)
271
+ # bare [] type annotation → array (e.g. `: []` → `: array`)
272
+ src = re.sub(r':\s*\[\]', ': array', src)
273
+ if src != original:
274
+ with open(fpath, "w", encoding="utf-8") as f:
275
+ f.write(src)
276
+ print(f" MIGRATED {fpath}")
277
+ changed += 1
278
+ else:
279
+ print(f" OK {fpath}")
280
+ except Exception as e:
281
+ errors += 1
282
+ print(f" ERR {fpath}: {e}", file=sys.stderr)
283
+
284
+ sep = "-" * 50
285
+ print(f"\n{sep}")
286
+ print(f" Migrated {changed} file{'s' if changed != 1 else ''}, "
287
+ f"{len(files)-changed-errors} already clean, "
288
+ f"{errors} error{'s' if errors != 1 else ''}")
289
+ print(sep)
290
+ return 1 if errors else 0
291
+
292
+
248
293
  def _fmt_all_files(directory: str) -> int:
249
294
  """v1.6.0: Format all .ins files in directory in-place. Returns exit code."""
250
295
  files = list(_find_ins_files(directory))
@@ -568,6 +613,8 @@ Examples:
568
613
  parser.add_argument("--check", action="store_true", help="Type-check only, don't run")
569
614
  parser.add_argument("--check-all", metavar="DIR",
570
615
  help="v1.6.0: Check all .ins files in DIR recursively, exit 1 if any errors")
616
+ parser.add_argument("--migrate", metavar="DIR_OR_FILE",
617
+ help="v1.7.4: Auto-migrate deprecated syntax (null→nil, div→//)")
571
618
  parser.add_argument("--strict", action="store_true",
572
619
  help="v1.6.0: Strict mode — all warnings become errors, no implicit any")
573
620
  parser.add_argument("--tokens", action="store_true", help="Print lexer token stream")
@@ -721,6 +768,8 @@ Examples:
721
768
  strict=getattr(args, 'strict', False))
722
769
  if getattr(args, 'fmt_all', None):
723
770
  return _fmt_all_files(args.fmt_all)
771
+ if getattr(args, 'migrate', None):
772
+ return _migrate_files(args.migrate)
724
773
 
725
774
  if not args.file:
726
775
  parser.print_help()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: inscript-lang
3
- Version: 1.6.0
3
+ Version: 1.7.3
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
@@ -39,7 +39,7 @@ class Interpreter(Visitor):
39
39
  self._env = self._globals
40
40
  self._call_depth = 0
41
41
  self._MAX_CALL_DEPTH = 500
42
- self._call_stack = InScriptCallStack(filename) # Phase 3.4
42
+ self._call_stack = InScriptCallStack(filename, src_lines=source_lines) # v1.7.3
43
43
 
44
44
  # v1.3.0: dispatch cache — build once, avoids getattr on every node visit
45
45
  self._dispatch: dict = {}
@@ -75,6 +75,22 @@ class Interpreter(Visitor):
75
75
 
76
76
  # ── entry point ───────────────────────────────────────────────────────────
77
77
 
78
+ # v1.7.3: Override Visitor.visit() to track current execution line in the
79
+ # top call-stack frame. This makes stack-trace line numbers point to the
80
+ # exact statement that was executing when an error occurred, rather than
81
+ # the call-site where the function was entered.
82
+ def visit(self, node) -> Any:
83
+ line = getattr(node, 'line', 0)
84
+ if line:
85
+ self._call_stack.update_top_line(line)
86
+ cls = type(node)
87
+ try:
88
+ return self._dispatch[cls](node)
89
+ except KeyError:
90
+ method = getattr(self, f"visit_{cls.__name__}", self.generic_visit)
91
+ self._dispatch[cls] = method
92
+ return method(node)
93
+
78
94
  def run(self, program: Program) -> Any:
79
95
  """Execute a full program."""
80
96
  return self.visit(program)
@@ -1288,16 +1304,18 @@ class Interpreter(Visitor):
1288
1304
 
1289
1305
  def visit_ThrowStmt(self, node) -> Any:
1290
1306
  val = self.visit(node.value)
1291
- # BUG-11 fix: tag error with the InScript type name of the thrown value
1292
- # so that `catch e: string` can match when `throw "hello"` is used
1293
1307
  if isinstance(val, str): etype = 'string'
1294
1308
  elif isinstance(val, bool): etype = 'bool'
1295
1309
  elif isinstance(val, int): etype = 'int'
1296
1310
  elif isinstance(val, float): etype = 'float'
1297
1311
  else: etype = 'Error'
1298
- err = InScriptRuntimeError(str(val), node.line)
1299
- err.error_type = etype
1300
- err.thrown_value = val
1312
+ # v1.7.3: capture full call stack at throw time
1313
+ trace = self._call_stack.snapshot()
1314
+ err = InScriptRuntimeError(str(val), node.line,
1315
+ source_line=self._src_line(node.line),
1316
+ call_trace=trace)
1317
+ err.error_type = etype
1318
+ err.thrown_value = val
1301
1319
  raise err
1302
1320
 
1303
1321
  def visit_TryExpr(self, node) -> Any:
@@ -1487,12 +1505,15 @@ class Interpreter(Visitor):
1487
1505
  def visit_FloatLiteralExpr(self, n): return n.value
1488
1506
  def visit_StringLiteralExpr(self,n): return n.value
1489
1507
  def visit_BoolLiteralExpr(self, n): return n.value
1490
- def visit_NullLiteralExpr(self, n):
1491
- # DESIGN-06: 'null' is a deprecated alias for 'nil' warn once per session
1492
- if not getattr(self, '_null_warned', False):
1493
- import sys
1494
- print("\033[33m[InScript] Warning: 'null' is deprecated — use 'nil' instead\033[0m", file=sys.stderr)
1495
- self._null_warned = True
1508
+ def visit_NullLiteralExpr(self, n):
1509
+ # v1.7.4: 'null' keyword is a hard error; 'nil' is correct
1510
+ if getattr(n, '_is_null_keyword', False):
1511
+ raise InScriptRuntimeError(
1512
+ "'null' was removed in v1.7.4 — use 'nil' instead. "
1513
+ "Run: inscript migrate <file> to auto-fix.",
1514
+ getattr(n, 'line', 0),
1515
+ code="E0055",
1516
+ )
1496
1517
  return None
1497
1518
 
1498
1519
  def visit_IdentExpr(self, node: IdentExpr) -> Any:
@@ -3292,6 +3313,46 @@ def _maybe_copy_value(val):
3292
3313
  return val
3293
3314
 
3294
3315
 
3316
+ def _format_float(val: float) -> str:
3317
+ """v1.7.1: Clean float display.
3318
+ 0.30000000000000004 → '0.3', 1.0 → '1.0',
3319
+ 1/3 → '0.3333333333333333', sqrt(2) → '1.4142135623730951'
3320
+ """
3321
+ if math.isinf(val): return "Infinity" if val > 0 else "-Infinity"
3322
+ if math.isnan(val): return "NaN"
3323
+ if val == int(val) and abs(val) < 1e15:
3324
+ return f"{int(val)}.0"
3325
+
3326
+ # Get the definitive repr (always round-trips correctly)
3327
+ s_repr = repr(val)
3328
+
3329
+ # Try 15 sig figs — may produce a shorter/cleaner string for float-noise values
3330
+ s15 = f"{val:.15g}"
3331
+ try:
3332
+ v15 = float(s15)
3333
+ # Only use 15g result if it's strictly shorter than repr AND round-trips close enough
3334
+ # "Shorter" means fewer significant digits → it genuinely simplified the value
3335
+ denom = abs(val) if val != 0 else 1.0
3336
+ rel_err = abs(v15 - val) / denom
3337
+ if rel_err < 2e-15 and len(s15.rstrip('0').rstrip('.')) <= len(s_repr) - 2:
3338
+ s = s15
3339
+ if '.' in s and 'e' not in s and 'E' not in s:
3340
+ s = s.rstrip('0')
3341
+ if s.endswith('.'):
3342
+ s += '0'
3343
+ return s
3344
+ except Exception:
3345
+ pass
3346
+
3347
+ # Use repr — strip redundant trailing zeros but keep all significant digits
3348
+ s = s_repr
3349
+ if '.' in s and 'e' not in s and 'E' not in s:
3350
+ s = s.rstrip('0')
3351
+ if s.endswith('.'):
3352
+ s += '0'
3353
+ return s
3354
+
3355
+
3295
3356
  def _inscript_str(val) -> str:
3296
3357
  if val is None: return "nil"
3297
3358
  if val is True: return "true"
@@ -3299,11 +3360,7 @@ def _inscript_str(val) -> str:
3299
3360
  if isinstance(val, list):
3300
3361
  return "[" + ", ".join(_inscript_repr(v) for v in val) + "]"
3301
3362
  if isinstance(val, float):
3302
- if math.isinf(val): return "Infinity" if val > 0 else "-Infinity"
3303
- if math.isnan(val): return "NaN"
3304
- if val == int(val):
3305
- return f"{int(val)}.0"
3306
- return str(val)
3363
+ return _format_float(val)
3307
3364
  if isinstance(val, str):
3308
3365
  return val # bare string — no quotes when printing top-level
3309
3366
  # InScript struct instance — show data fields only, no methods
@@ -15,7 +15,8 @@ class TT(Enum):
15
15
  FLOAT = auto()
16
16
  STRING = auto()
17
17
  BOOL = auto()
18
- NULL = auto()
18
+ NULL = auto() # null (removed — hard error)
19
+ NIL = auto() # nil (the correct nil literal)
19
20
 
20
21
  # ── Identifiers & Keywords ────────────────────
21
22
  IDENT = auto()
@@ -175,7 +176,7 @@ KEYWORDS: dict = {
175
176
  "true": TT.BOOL,
176
177
  "false": TT.BOOL,
177
178
  "null": TT.NULL,
178
- "nil": TT.NULL, # alias for null
179
+ "nil": TT.NIL,
179
180
  "import": TT.IMPORT,
180
181
  "from": TT.FROM,
181
182
  "as": TT.AS,
@@ -291,16 +292,26 @@ class Lexer:
291
292
  if ch in (" ", "\t", "\r", "\n"):
292
293
  return
293
294
 
294
- # // — floor-division or line comment
295
- # Floor division: `10 // 3`, `x // 2`, `(a+b) // c`
296
- # Comment: `let x = 5 // this is a comment`
297
- # Key rule: look at what comes AFTER the second /
298
- # - digit or (always floor division
299
- # - letter/word always comment
300
- # Phase 1.2: // is now ALWAYS a line comment. Floor division uses 'div' keyword.
295
+ # // — floor-division operator or line comment
296
+ # v1.7.1: `//` is now the integer division operator (10 // 3 → 3)
297
+ # Line comment: `let x = 5 // this is a comment` — space before //
298
+ # Floor division: `10 // 3`, `x//2`, `(a+b) // c`
299
+ # Rule: if the character BEFORE // was a digit, ), ], or identifier char → floor div
300
+ # otherwiseline comment
301
301
  if ch == "/" and self.current == "/":
302
302
  self.advance() # consume second /
303
- self._skip_line_comment()
303
+ # Determine: floor-div or line comment?
304
+ # Rule: the character IMMEDIATELY before first '/' (no whitespace allowed)
305
+ # must be digit/identifier/closing bracket to be floor-div.
306
+ # Any space/tab before the // → line comment.
307
+ first_slash_pos = self.pos - 2
308
+ prev = self.source[first_slash_pos - 1] if first_slash_pos > 0 else ''
309
+ if prev and (prev.isdigit() or prev.isalpha() or prev in ')]}'):
310
+ # `10//3`, `x//2`, `(a+b)//c` — no space → floor division
311
+ self._emit(TT.SLASH_SLASH, "//", sl, sc)
312
+ else:
313
+ # `10 // 3` with spaces, `5 // comment`, `// standalone` → comment
314
+ self._skip_line_comment()
304
315
  return
305
316
  if ch == "/" and self.current == "*":
306
317
  self._skip_block_comment(sl, sc); return
@@ -497,7 +508,8 @@ class Lexer:
497
508
  text = "".join(chars)
498
509
  tt = KEYWORDS.get(text, TT.IDENT)
499
510
  if tt == TT.BOOL: value = (text == "true")
500
- elif tt == TT.NULL: value = None
511
+ elif tt == TT.NIL: value = None # nil literal → Python None
512
+ elif tt == TT.NULL: value = None # null (removed keyword — still lex it so error can fire)
501
513
  else: value = text
502
514
  self._emit(tt, value, sl, sc)
503
515
 
@@ -522,8 +534,8 @@ class Lexer:
522
534
  elif self.match("="): emit(TT.STAR_EQ, "*=")
523
535
  else: emit(TT.STAR, "*")
524
536
  elif ch == "/":
525
- if self.match("="): emit(TT.SLASH_EQ, "/=")
526
- else: emit(TT.SLASH, "/")
537
+ if self.match("="): emit(TT.SLASH_EQ, "/=")
538
+ else: emit(TT.SLASH, "/")
527
539
  elif ch == "%":
528
540
  if self.match("="): emit(TT.PERCENT_EQ, "%=")
529
541
  else: emit(TT.PERCENT, "%")
@@ -444,6 +444,9 @@ class Parser:
444
444
  TT.STRING_TYPE, TT.VOID_TYPE, TT.IDENT):
445
445
  name = tok.value
446
446
  self.advance()
447
+ elif tok.type == TT.NIL: # v1.7.2: nil as type annotation
448
+ name = "nil"
449
+ self.advance()
447
450
  else:
448
451
  self._error(f"Expected type name, got '{tok.value}'")
449
452
  ann = TypeAnnotation(name=name, line=line, col=col)
@@ -885,8 +888,10 @@ class Parser:
885
888
  # e.g. items: [] or count: 0 or value: nil
886
889
  type_ann = None
887
890
  _type_starts = (TT.IDENT, TT.INT_TYPE, TT.FLOAT_TYPE, TT.BOOL_TYPE,
888
- TT.STRING_TYPE, TT.VOID_TYPE, TT.LBRACE, TT.LPAREN)
891
+ TT.STRING_TYPE, TT.VOID_TYPE, TT.LBRACE, TT.LPAREN,
892
+ TT.NIL) # v1.7.2: nil is a valid type (nullable/any)
889
893
  # Tokens that unambiguously start a literal default (not a type name)
894
+ # Note: TT.NIL removed — could be type OR default; handled by _type_starts
890
895
  _default_starts = (TT.NULL, TT.BOOL, TT.INT, TT.FLOAT, TT.STRING, TT.MINUS)
891
896
 
892
897
  if self.check(TT.LBRACKET):
@@ -1623,7 +1628,7 @@ class Parser:
1623
1628
  attr_tok = self.current
1624
1629
  # Accept any identifier OR keyword as attribute name (e.g. regex.match, uuid.nil)
1625
1630
  _KEYWORD_TYPES = {
1626
- TT.NULL, TT.MATCH, TT.IN, TT.FOR, TT.IF, TT.ELSE, TT.WHILE,
1631
+ TT.NIL, TT.NULL, TT.MATCH, TT.IN, TT.FOR, TT.IF, TT.ELSE, TT.WHILE,
1627
1632
  TT.RETURN, TT.BREAK, TT.CONTINUE, TT.LET, TT.CONST, TT.FN,
1628
1633
  TT.STRUCT, TT.ENUM, TT.IMPORT, TT.EXPORT, TT.FROM, TT.ASYNC,
1629
1634
  TT.AWAIT, TT.ABSTRACT, TT.SELECT, TT.CASE, TT.TRY, TT.CATCH,
@@ -1634,7 +1639,7 @@ class Parser:
1634
1639
  }
1635
1640
  if attr_tok.type == TT.IDENT or attr_tok.type in _KEYWORD_TYPES:
1636
1641
  # For NULL token use "nil"; for BOOL use the literal text ("true"/"false")
1637
- if attr_tok.type == TT.NULL:
1642
+ if attr_tok.type in (TT.NIL, TT.NULL):
1638
1643
  attr = "nil"
1639
1644
  elif attr_tok.type == TT.BOOL:
1640
1645
  attr = "true" if attr_tok.value else "false"
@@ -1694,7 +1699,7 @@ class Parser:
1694
1699
  # Propagate ? is followed by newline, }, ), ,, ;, EOF
1695
1700
  elif self.check(TT.QUESTION):
1696
1701
  _EXPR_START = {
1697
- TT.STRING, TT.INT, TT.FLOAT, TT.BOOL, TT.NULL,
1702
+ TT.STRING, TT.INT, TT.FLOAT, TT.BOOL, TT.NIL, TT.NULL,
1698
1703
  TT.IDENT, TT.LPAREN, TT.LBRACKET, TT.LBRACE,
1699
1704
  TT.BIT_OR, TT.OR, TT.NOT, TT.MINUS, TT.FSTRING,
1700
1705
  TT.INT_TYPE, TT.FLOAT_TYPE, TT.STRING_TYPE, TT.BOOL_TYPE,
@@ -1735,7 +1740,7 @@ class Parser:
1735
1740
  _CHECK_TYPES = {
1736
1741
  TT.INT_TYPE: "int", TT.FLOAT_TYPE: "float",
1737
1742
  TT.STRING_TYPE: "string", TT.BOOL_TYPE: "bool",
1738
- TT.VOID_TYPE: "void", TT.NULL: "nil",
1743
+ TT.VOID_TYPE: "void", TT.NULL: "nil", TT.NIL: "nil",
1739
1744
  TT.IDENT: None,
1740
1745
  }
1741
1746
  if check_tok.type in _CHECK_TYPES:
@@ -1813,10 +1818,16 @@ class Parser:
1813
1818
  self.advance()
1814
1819
  return BoolLiteralExpr(value=tok.value, line=line, col=col)
1815
1820
 
1816
- # Null
1817
- if tok.type == TT.NULL:
1821
+ # nil / null
1822
+ if tok.type == TT.NIL:
1818
1823
  self.advance()
1819
1824
  return NullLiteralExpr(line=line, col=col)
1825
+ if tok.type == TT.NULL:
1826
+ self.advance()
1827
+ # Keep the node but tag it as the removed 'null' keyword
1828
+ node = NullLiteralExpr(line=line, col=col)
1829
+ node._is_null_keyword = True # interpreter will hard-error on this
1830
+ return node
1820
1831
 
1821
1832
  # Grouped expression: (expr) OR Tuple: (expr, expr, ...)
1822
1833
  if tok.type == TT.LPAREN:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "inscript-lang"
7
- version = "1.6.0"
7
+ version = "1.7.3"
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 = "1.6.0"
43
+ VERSION = "1.7.3"
44
44
 
45
45
  # ── ANSI colours ──────────────────────────────────────────────────────────────
46
46
  def _c(code, text):
@@ -434,6 +434,9 @@ class EnhancedREPL:
434
434
  self._history: List[str] = []
435
435
  self._session: List[str] = []
436
436
  self._last_src: str = ""
437
+ # v1.7.4: mark the root env as REPL-mode so `let` re-declarations
438
+ # silently re-bind instead of erroring ("let x=1" then "let x=2" works)
439
+ self._interp._env._repl_mode = True
437
440
  self._setup_readline()
438
441
 
439
442
  def _setup_readline(self):
@@ -457,6 +460,13 @@ class EnhancedREPL:
457
460
  from parser import Parser
458
461
  from errors import InScriptError
459
462
  t0 = time.perf_counter()
463
+
464
+ # v1.7.4: snapshot the top-level env so a runtime error can't corrupt
465
+ # previously-defined globals. On error we restore the snapshot, which
466
+ # means any partial defines from the failed block are rolled back —
467
+ # already-defined names from prior REPL lines are always preserved.
468
+ env_snapshot = dict(self._interp._env._store)
469
+
460
470
  try:
461
471
  tokens = Lexer(source).tokenize()
462
472
  program = Parser(tokens).parse()
@@ -474,8 +484,11 @@ class EnhancedREPL:
474
484
  result = self._interp.run(program)
475
485
  return result, None, (time.perf_counter() - t0) * 1000
476
486
  except InScriptError as e:
487
+ # v1.7.4: restore globals so surviving names are untouched
488
+ self._interp._env._store = env_snapshot
477
489
  return None, _format_error(str(e), source), (time.perf_counter() - t0) * 1000
478
490
  except Exception as e:
491
+ self._interp._env._store = env_snapshot
479
492
  import traceback as _tb
480
493
  return None, f"Internal error: {e}\n{_tb.format_exc()}", (time.perf_counter() - t0) * 1000
481
494
 
@@ -11,7 +11,7 @@ from setuptools import setup, find_packages
11
11
 
12
12
  setup(
13
13
  name = "inscript-lang",
14
- version = "1.6.0",
14
+ version = "1.7.2",
15
15
  author = "Shreyasi Sarkar",
16
16
  description = "InScript — a game-focused scripting language for 2D games",
17
17
  long_description = open("README.md", encoding="utf-8").read(),
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes