sutra-dev 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,552 @@
1
+ """High-level validator for Sutra source.
2
+
3
+ The validator runs after the parser. It walks the AST and emits
4
+ diagnostics for rules that the syntax-decisions document calls out as
5
+ errors but that aren't enforced by the pure parser.
6
+
7
+ Rules implemented in v0.1:
8
+
9
+ - SUT0103: `var TYPE x` — `var` combined with an explicit type. (The
10
+ parser already emits this; the validator doesn't need to re-check.)
11
+ - SUT0110: `|>` pipe-forward operator is not supported in Sutra.
12
+ - SUT0111: `(vector) "string"` (or any primitive-cast applied to a
13
+ string literal) — per the spec, string→vector must go through
14
+ `embed(...)`, not a cast.
15
+ - SUT0112: modifiers combined in disallowed ways (e.g. both `public`
16
+ and `private`).
17
+ - SUT0113: naming drift — the file uses class names in inconsistent
18
+ casing (a warning, not an error, because both are currently
19
+ accepted in the example code).
20
+
21
+ v0.1 deliberately does NOT do:
22
+
23
+ - Type checking across declarations
24
+ - Name resolution (unknown identifiers)
25
+ - Arity checking on calls
26
+ - Return-statement coverage
27
+
28
+ Those land in v0.2+ once we have a symbol table and cross-module
29
+ resolution.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ from typing import List, Optional, Set
35
+
36
+ from . import ast_nodes as ast
37
+ from .diagnostics import (
38
+ DiagnosticBag,
39
+ DiagnosticLevel,
40
+ SourcePosition,
41
+ SourceSpan,
42
+ )
43
+ from .lexer import Lexer, TokenKind
44
+ from .parser import Parser
45
+
46
+
47
+ def _fuzzy_literal_constant(expr: ast.Expr) -> Optional[float]:
48
+ """Fold a literal (possibly with unary +/-) to a single scalar.
49
+
50
+ Returns None for anything needing runtime evaluation. Used to
51
+ range-check fuzzy-typed literal initializers.
52
+ """
53
+ if isinstance(expr, ast.FloatLiteral):
54
+ return float(expr.value)
55
+ if isinstance(expr, ast.IntLiteral):
56
+ return float(expr.value)
57
+ if isinstance(expr, ast.BoolLiteral):
58
+ return 1.0 if expr.value else -1.0
59
+ if isinstance(expr, ast.UnknownLiteral):
60
+ return 0.0
61
+ if isinstance(expr, ast.UnaryOp) and expr.op in ("-", "+"):
62
+ inner = _fuzzy_literal_constant(expr.operand)
63
+ if inner is None:
64
+ return None
65
+ return -inner if expr.op == "-" else inner
66
+ if isinstance(expr, ast.Parenthesized):
67
+ return _fuzzy_literal_constant(expr.inner)
68
+ return None
69
+
70
+
71
+ # ============================================================
72
+ # Public entry points
73
+ # ============================================================
74
+
75
+
76
+ def validate_source(
77
+ source: str,
78
+ *,
79
+ file: Optional[str] = None,
80
+ ) -> DiagnosticBag:
81
+ """Lex, parse, and validate a string of Sutra source."""
82
+ lexer = Lexer(source, file=file)
83
+ tokens = lexer.tokenize()
84
+ bag = lexer.diagnostics
85
+ parser = Parser(tokens, file=file, diagnostics=bag)
86
+ module = parser.parse_module()
87
+ _check_pipe_forward(tokens, bag)
88
+ _Walker(bag).visit_module(module)
89
+ return bag
90
+
91
+
92
+ def validate_file(path: str) -> DiagnosticBag:
93
+ """Validate a file on disk."""
94
+ with open(path, "r", encoding="utf-8") as f:
95
+ source = f.read()
96
+ return validate_source(source, file=path)
97
+
98
+
99
+ # ============================================================
100
+ # Token-level checks (before AST walk)
101
+ # ============================================================
102
+
103
+
104
+ def _check_pipe_forward(tokens, bag: DiagnosticBag) -> None:
105
+ """Flag any `|>` pipe-forward tokens.
106
+
107
+ The spec is explicit: Sutra does not have a pipe operator; use
108
+ nested calls or method chaining instead.
109
+ """
110
+ for tok in tokens:
111
+ if tok.kind is TokenKind.PIPE_FORWARD:
112
+ bag.error(
113
+ "the `|>` pipe-forward operator is not supported in Sutra",
114
+ tok.span,
115
+ code="SUT0110",
116
+ hint="use nested calls (`Normalize(Blend(a, b))`) or method chaining (`a.Blend(b).Normalize()`)",
117
+ )
118
+
119
+
120
+ # ============================================================
121
+ # AST walker
122
+ # ============================================================
123
+
124
+
125
+ class _Walker:
126
+ """AST walker that runs validator rules.
127
+
128
+ Each visit_X method handles one node type. Unhandled nodes fall
129
+ back to a generic child-walk so we always reach every expression.
130
+ """
131
+
132
+ def __init__(self, diagnostics: DiagnosticBag) -> None:
133
+ self.diagnostics = diagnostics
134
+ self._class_name_usages: Set[str] = set()
135
+ # User-declared classes — name → parent_name. Populated by
136
+ # visit_ClassDecl. Used to walk inheritance chains and to
137
+ # check that user-defined types in type positions actually
138
+ # resolve to a declared class.
139
+ self._class_decls: dict = {}
140
+ # `wait`-declared variables in the *current* function scope.
141
+ # Maps name → declaration span, populated when a `var x = wait;`
142
+ # is seen. Cleared on function entry so wait-tracking is
143
+ # function-local. When the function body finishes, any name
144
+ # still in this dict has never been assigned and gets a
145
+ # SUT0130 error.
146
+ self._wait_declared: dict = {}
147
+ # Names declared at file scope (top-level FunctionDecl,
148
+ # MethodDecl, VarDecl, ClassDecl, LoopFunctionDecl). Populated
149
+ # in visit_module before the per-item walk. Used by the object-
150
+ # encapsulation rule (SUT0144): object methods cannot read
151
+ # file-scope names — see planning/open-questions/
152
+ # function-taxonomy-and-closure.md.
153
+ self._file_scope_names: Set[str] = set()
154
+ # When inside a method body, the set of names locally in
155
+ # scope (params + body var decls). None when not in a method.
156
+ # The encapsulation rule fires on any Identifier whose name is
157
+ # in _file_scope_names AND not in _method_local_names while
158
+ # _method_local_names is non-None.
159
+ self._method_local_names: Optional[Set[str]] = None
160
+
161
+ # ---- module ----------------------------------------------------
162
+
163
+ def visit_module(self, module: ast.Module) -> None:
164
+ # Pre-pass: collect every top-level declaration's name into the
165
+ # file-scope set, EXCEPT class names. Class names are
166
+ # namespace anchors — `Math.log(x)` from inside a method is
167
+ # legitimate access through the class boundary, not a
168
+ # file-scope read. The encapsulation rule (SUT0144) fires on
169
+ # bare references to file-scope free functions, top-level vars,
170
+ # top-level methods, and top-level loop functions.
171
+ for item in module.items:
172
+ if isinstance(item, ast.ClassDecl):
173
+ continue
174
+ name = getattr(item, "name", None)
175
+ if isinstance(name, str) and name:
176
+ self._file_scope_names.add(name)
177
+ for item in module.items:
178
+ self.visit(item)
179
+ self._check_class_casing_drift()
180
+
181
+ # ---- dispatch --------------------------------------------------
182
+
183
+ def visit(self, node) -> None:
184
+ method_name = f"visit_{type(node).__name__}"
185
+ method = getattr(self, method_name, None)
186
+ if method is not None:
187
+ method(node)
188
+ else:
189
+ self._walk_children(node)
190
+
191
+ def _walk_children(self, node) -> None:
192
+ # Generic walk: visit every field that's an AST node or a list
193
+ # of AST nodes. This is enough for the v0.1 validator; richer
194
+ # traversal can come later.
195
+ for attr in vars(node).values():
196
+ if isinstance(attr, (ast.Node, ast.Module)):
197
+ self.visit(attr)
198
+ elif isinstance(attr, list):
199
+ for item in attr:
200
+ if isinstance(item, ast.Node):
201
+ self.visit(item)
202
+
203
+ # ---- declarations ----------------------------------------------
204
+
205
+ def visit_ClassDecl(self, node: ast.ClassDecl) -> None:
206
+ # Detect duplicate class declarations.
207
+ if node.name in self._class_decls:
208
+ self.diagnostics.error(
209
+ f"class `{node.name}` is already declared in this module",
210
+ node.span,
211
+ code="SUT0141",
212
+ )
213
+ # Still walk methods so any in-method diagnostics fire.
214
+ for m in node.methods:
215
+ self.visit(m)
216
+ return
217
+
218
+ # Walk the would-be inheritance chain to verify it bottoms
219
+ # out at a primitive. The parent must be either a primitive
220
+ # type name or a previously-declared user class. Forward
221
+ # references aren't supported in MVP — declarations are
222
+ # expected to be in dependency order.
223
+ from .lexer import PRIMITIVE_TYPE_NAMES
224
+
225
+ parent = node.parent_name
226
+ if parent in PRIMITIVE_TYPE_NAMES:
227
+ self._class_decls[node.name] = parent
228
+ elif parent not in self._class_decls:
229
+ self.diagnostics.error(
230
+ f"class `{node.name}` extends `{parent}`, which is not a "
231
+ "primitive type and has not been declared earlier in this "
232
+ "module. The MVP class system requires the extends-chain "
233
+ "to bottom out at a primitive (vector / int / float / "
234
+ "fuzzy / etc.) and does not support forward references",
235
+ node.span,
236
+ code="SUT0142",
237
+ hint=f"declare `class {parent} extends <something> {{ }}` "
238
+ "before this declaration, or change `extends` to a "
239
+ "primitive type name",
240
+ )
241
+ # Still register the class so downstream references don't
242
+ # double-error. Mark with a sentinel so we know the chain
243
+ # is broken.
244
+ self._class_decls[node.name] = parent
245
+ else:
246
+ # Walk transitively to confirm the chain ultimately reaches
247
+ # a primitive (it should, by induction, but a malformed
248
+ # earlier decl can poison the chain — we already errored on
249
+ # it, so just treat this one as OK for downstream usage).
250
+ self._class_decls[node.name] = parent
251
+
252
+ # Walk methods declared inside the class body. Each is
253
+ # validator-visited via the existing visit_MethodDecl, which
254
+ # enforces the encapsulation rule (SUT0144) on the body.
255
+ for m in node.methods:
256
+ self.visit(m)
257
+ # Walk loop function declarations declared inside the class
258
+ # body (object loops). Same visitor path as top-level loop
259
+ # function decls.
260
+ for lf in node.loop_functions:
261
+ self.visit(lf)
262
+
263
+ def visit_FunctionDecl(self, node: ast.FunctionDecl) -> None:
264
+ self._check_modifier_conflict(node.modifiers, node.span)
265
+ self._record_type_usage(node.return_type)
266
+ for p in node.params:
267
+ self._record_type_usage(p.type_ref)
268
+ self._enter_function_scope()
269
+ self.visit(node.body)
270
+ self._exit_function_scope()
271
+
272
+ def visit_MethodDecl(self, node: ast.MethodDecl) -> None:
273
+ self._check_modifier_conflict(node.modifiers, node.span)
274
+ self._record_type_usage(node.return_type)
275
+ for p in node.params:
276
+ self._record_type_usage(p.type_ref)
277
+ self._enter_function_scope()
278
+ # Encapsulation rule (SUT0144): walking the method body, any
279
+ # bare Identifier whose name is in _file_scope_names but not in
280
+ # _method_local_names is forbidden. Seed the local set with the
281
+ # method's params; visit_VarDecl extends it as `var x = ...;`
282
+ # decls are seen inside the body.
283
+ saved_method_scope = self._method_local_names
284
+ self._method_local_names = {p.name for p in node.params}
285
+ try:
286
+ self.visit(node.body)
287
+ finally:
288
+ self._method_local_names = saved_method_scope
289
+ self._exit_function_scope()
290
+
291
+ def visit_VarDecl(self, node: ast.VarDecl) -> None:
292
+ if node.type_ref is not None:
293
+ self._record_type_usage(node.type_ref)
294
+ # `wait`-initialized declarations: register the name as a
295
+ # pending wait (assigned-later promise) and do NOT descend into
296
+ # the initializer — `WaitLiteral` is legal here, illegal
297
+ # everywhere else, and the position check below catches the
298
+ # everywhere-else case.
299
+ if isinstance(node.initializer, ast.WaitLiteral):
300
+ if self._method_local_names is not None:
301
+ self._method_local_names.add(node.name)
302
+ # Top-level `wait` has no enclosing function body to
303
+ # assign in — reject it. Use the wait stack as the
304
+ # in-function indicator (it's pushed by _enter_function_scope).
305
+ if not getattr(self, "_wait_stack", []):
306
+ self.diagnostics.error(
307
+ "`wait` is only valid inside a function or method "
308
+ "body — top-level declarations don't have a later "
309
+ "execution flow to assign in",
310
+ node.span,
311
+ code="SUT0133",
312
+ hint="move the declaration into a function body, "
313
+ "or initialize it with a concrete value at the "
314
+ "top level",
315
+ )
316
+ return
317
+ if node.type_ref is None:
318
+ # `var x = wait;` (inferred) has no type to default the
319
+ # zero-of-type emission to. Require an explicit type.
320
+ self.diagnostics.error(
321
+ "`var x = wait;` (inferred) is not allowed — "
322
+ "`wait` requires an explicit type so the compiler "
323
+ "knows the zero-of-type to allocate at the "
324
+ "declaration site",
325
+ node.span,
326
+ code="SUT0131",
327
+ hint="write `int x = wait;` (or another concrete type) "
328
+ "instead, or use `var x : TYPE;` for the same "
329
+ "uninitialized-slot semantics without the explicit "
330
+ "deferred-init signal",
331
+ )
332
+ else:
333
+ self._wait_declared[node.name] = node.span
334
+ return
335
+ if node.initializer is not None:
336
+ self.visit(node.initializer)
337
+ # After the initializer is checked (so `var x = file_scope_name;`
338
+ # inside a method body still flags the file-scope read), register
339
+ # x as method-local so subsequent references inside the body
340
+ # don't trip the encapsulation rule.
341
+ if self._method_local_names is not None:
342
+ self._method_local_names.add(node.name)
343
+ # Fuzzy / trit literals live on the truth axis which the
344
+ # spec defines over [-1, +1]. A literal outside that range is
345
+ # almost always a mistake; warn (not error) so existing programs
346
+ # don't break while the rule beds in.
347
+ if (node.type_ref is not None
348
+ and node.type_ref.name in ("fuzzy", "trit")
349
+ and node.initializer is not None):
350
+ value = _fuzzy_literal_constant(node.initializer)
351
+ if value is not None and (value < -1.0 or value > 1.0):
352
+ self.diagnostics.warning(
353
+ f"{node.type_ref.name} literal {value!r} is outside "
354
+ "[-1, +1] — the truth axis saturates at ±1. "
355
+ "Did you mean a different type?",
356
+ node.span,
357
+ code="SUT0120",
358
+ hint="use a `scalar` for unbounded values, or clamp the "
359
+ "literal into [-1, +1]",
360
+ )
361
+
362
+ def visit_Param(self, node: ast.Param) -> None:
363
+ self._record_type_usage(node.type_ref)
364
+
365
+ def visit_Identifier(self, node: ast.Identifier) -> None:
366
+ # Object-encapsulation rule (SUT0144): when we are inside a
367
+ # method body (`_method_local_names` is non-None), any bare
368
+ # identifier whose name is declared at file scope but is not
369
+ # locally bound (param or var decl in the body) is forbidden.
370
+ # Object methods are encapsulated within the class boundary —
371
+ # static methods see the class as namespace; non-static
372
+ # methods see `this` only. File-scope visibility is for free
373
+ # functions only.
374
+ # See planning/open-questions/function-taxonomy-and-closure.md.
375
+ if self._method_local_names is None:
376
+ return
377
+ if node.name in self._method_local_names:
378
+ return
379
+ if node.name in self._file_scope_names:
380
+ self.diagnostics.error(
381
+ f"object methods cannot read file-scope name `{node.name}` — "
382
+ "object methods are encapsulated within the class boundary "
383
+ "(static methods see the class as namespace; non-static "
384
+ "methods see `this` only). File-scope visibility is for "
385
+ "free functions only.",
386
+ node.span,
387
+ code="SUT0144",
388
+ hint=(
389
+ f"if `{node.name}` should be accessible to this method, "
390
+ "either make this a free function (`function` instead of "
391
+ f"`method`), or move `{node.name}` onto a class so the "
392
+ "method can reach it through `this.` or the class as a "
393
+ "namespace."
394
+ ),
395
+ )
396
+
397
+ # ---- statements ------------------------------------------------
398
+
399
+ def visit_Block(self, node: ast.Block) -> None:
400
+ for s in node.statements:
401
+ self.visit(s)
402
+
403
+ def visit_IfStmt(self, node: ast.IfStmt) -> None:
404
+ self.visit(node.condition)
405
+ self.visit(node.then_branch)
406
+ if node.else_branch is not None:
407
+ self.visit(node.else_branch)
408
+
409
+ def visit_ForStmt(self, node: ast.ForStmt) -> None:
410
+ if node.init is not None:
411
+ self.visit(node.init)
412
+ if node.condition is not None:
413
+ self.visit(node.condition)
414
+ if node.step is not None:
415
+ self.visit(node.step)
416
+ self.visit(node.body)
417
+
418
+ def visit_ForeachStmt(self, node: ast.ForeachStmt) -> None:
419
+ if node.var_type is not None:
420
+ self._record_type_usage(node.var_type)
421
+ self.visit(node.iterable)
422
+ self.visit(node.body)
423
+
424
+ # ---- expressions -----------------------------------------------
425
+
426
+ def visit_CastExpr(self, node: ast.CastExpr) -> None:
427
+ # SUT0111: (TYPE) "string literal" is not allowed. String ->
428
+ # vector must go through embed(), per the spec.
429
+ if isinstance(node.expr, ast.StringLiteral):
430
+ self.diagnostics.error(
431
+ f"cannot cast a string literal to `{node.target_type.name}`; "
432
+ "use `embed(...)` to convert a string into a vector",
433
+ node.span,
434
+ code="SUT0111",
435
+ hint="write `embed(\"...\")` instead of `({}) \"...\"`".format(
436
+ node.target_type.name
437
+ ),
438
+ )
439
+ self._record_type_usage(node.target_type)
440
+ self.visit(node.expr)
441
+
442
+ def visit_UnsafeCastExpr(self, node: ast.UnsafeCastExpr) -> None:
443
+ self._record_type_usage(node.target_type)
444
+ self.visit(node.expr)
445
+
446
+ def visit_Call(self, node: ast.Call) -> None:
447
+ for t in node.type_args:
448
+ self._record_type_usage(t)
449
+ self.visit(node.callee)
450
+ for a in node.args:
451
+ self.visit(a)
452
+
453
+ def visit_WaitLiteral(self, node: ast.WaitLiteral) -> None:
454
+ # The only place `wait` is legal is the RHS of a var-decl
455
+ # initializer, which `visit_VarDecl` handles by short-circuiting
456
+ # before descending. If we reach the literal through any other
457
+ # path, it's a position error.
458
+ self.diagnostics.error(
459
+ "`wait` is only valid as a var-decl initializer "
460
+ "(`int i = wait;`); it cannot appear in other expression "
461
+ "positions",
462
+ node.span,
463
+ code="SUT0130",
464
+ hint="if you want a placeholder value, use a typed zero "
465
+ "(`0`, `unknown`, the zero vector); if you want explicit "
466
+ "deferred initialization, move `wait` to a declaration",
467
+ )
468
+
469
+ def visit_Assignment(self, node: ast.Assignment) -> None:
470
+ # If this assigns to a wait-declared name, mark the wait as
471
+ # satisfied. Single-name targets only — assignments to fields
472
+ # / subscripts don't satisfy a wait on the variable itself.
473
+ if (isinstance(node.target, ast.Identifier)
474
+ and node.target.name in self._wait_declared):
475
+ del self._wait_declared[node.target.name]
476
+ self.visit(node.value)
477
+
478
+ # ---- helpers ---------------------------------------------------
479
+
480
+ def _enter_function_scope(self) -> None:
481
+ # Save the outer wait-tracking state and start a fresh scope.
482
+ # Function definitions can be nested (a method inside a class
483
+ # inside another method-bearing decl, for instance), so we
484
+ # need to restore on exit rather than just clearing.
485
+ self._wait_stack = getattr(self, "_wait_stack", [])
486
+ self._wait_stack.append(self._wait_declared)
487
+ self._wait_declared = {}
488
+
489
+ def _exit_function_scope(self) -> None:
490
+ # Any wait-declared name still pending at function exit was
491
+ # never assigned. Per the wait spec ("if it's not declared at
492
+ # all, it throws an error"), that's an error.
493
+ for name, span in self._wait_declared.items():
494
+ self.diagnostics.error(
495
+ f"variable `{name}` was declared with `wait` but never "
496
+ "assigned in the function body — `wait` is a promise "
497
+ "that an assignment will follow before the value is read",
498
+ span,
499
+ code="SUT0132",
500
+ hint=f"add `{name} = <value>;` somewhere in this function, "
501
+ "or remove the `wait` initializer if you intended "
502
+ "the zero-of-type as the final value",
503
+ )
504
+ self._wait_declared = self._wait_stack.pop() if self._wait_stack else {}
505
+
506
+ def _check_modifier_conflict(
507
+ self, mods: ast.Modifiers, span: SourceSpan
508
+ ) -> None:
509
+ if mods.is_public and mods.is_private:
510
+ self.diagnostics.error(
511
+ "a declaration cannot be both `public` and `private`",
512
+ span,
513
+ code="SUT0112",
514
+ )
515
+
516
+ def _record_type_usage(self, type_ref: Optional[ast.TypeRef]) -> None:
517
+ if type_ref is None:
518
+ return
519
+ # Only track user-defined types (not primitives) so we can
520
+ # detect casing drift on the same logical name.
521
+ PRIMITIVES = {
522
+ "scalar", "vector", "matrix", "tuple", "string",
523
+ "bool", "fuzzy", "void", "permutation", "map",
524
+ }
525
+ if type_ref.name not in PRIMITIVES:
526
+ self._class_name_usages.add(type_ref.name)
527
+ for arg in type_ref.type_args:
528
+ self._record_type_usage(arg)
529
+
530
+ def _check_class_casing_drift(self) -> None:
531
+ # Detect when the same class name appears in multiple casings
532
+ # within a single file. e.g. `animal` and `Animal`. We don't
533
+ # know which is canonical, so we emit a single warning listing
534
+ # both variants.
535
+ by_lower: dict = {}
536
+ for name in self._class_name_usages:
537
+ by_lower.setdefault(name.lower(), set()).add(name)
538
+ for variants in by_lower.values():
539
+ if len(variants) > 1:
540
+ sorted_variants = sorted(variants)
541
+ joined = ", ".join(f"`{v}`" for v in sorted_variants)
542
+ # Use a zero-length span at position 1,1 since this is
543
+ # a file-level concern. The SUT0113 code makes it
544
+ # editor-filterable.
545
+ pos = SourcePosition(line=1, column=1, offset=0)
546
+ self.diagnostics.warning(
547
+ f"class name appears in multiple casings in the same file: {joined}",
548
+ SourceSpan(start=pos, end=pos),
549
+ code="SUT0113",
550
+ hint="pick one casing and use it consistently — the spec "
551
+ "follows C# naming (PascalCase for class names)",
552
+ )