spice-lang 0.2.2__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 (62) hide show
  1. spice_lang-0.2.2/PKG-INFO +27 -0
  2. spice_lang-0.2.2/pyproject.toml +48 -0
  3. spice_lang-0.2.2/setup.cfg +4 -0
  4. spice_lang-0.2.2/spice/__init__.py +8 -0
  5. spice_lang-0.2.2/spice/annotations/__init__.py +15 -0
  6. spice_lang-0.2.2/spice/annotations/base.py +24 -0
  7. spice_lang-0.2.2/spice/annotations/builtins/__init__.py +6 -0
  8. spice_lang-0.2.2/spice/annotations/builtins/print_on_call.py +34 -0
  9. spice_lang-0.2.2/spice/annotations/registry.py +26 -0
  10. spice_lang-0.2.2/spice/cli/__init__.py +3 -0
  11. spice_lang-0.2.2/spice/cli/cli_handler.py +38 -0
  12. spice_lang-0.2.2/spice/compilation/__init__.py +8 -0
  13. spice_lang-0.2.2/spice/compilation/build_flags.py +105 -0
  14. spice_lang-0.2.2/spice/compilation/checks/__init__.py +22 -0
  15. spice_lang-0.2.2/spice/compilation/checks/annotation_stage.py +87 -0
  16. spice_lang-0.2.2/spice/compilation/checks/compile_time_check.py +16 -0
  17. spice_lang-0.2.2/spice/compilation/checks/final_checker.py +189 -0
  18. spice_lang-0.2.2/spice/compilation/checks/generic_bound_checker.py +133 -0
  19. spice_lang-0.2.2/spice/compilation/checks/interface_checker.py +149 -0
  20. spice_lang-0.2.2/spice/compilation/checks/method_overload_resolver.py +117 -0
  21. spice_lang-0.2.2/spice/compilation/checks/symbol_table_builder.py +223 -0
  22. spice_lang-0.2.2/spice/compilation/checks/type_checker.py +321 -0
  23. spice_lang-0.2.2/spice/compilation/cython_compiler.py +143 -0
  24. spice_lang-0.2.2/spice/compilation/pipeline.py +317 -0
  25. spice_lang-0.2.2/spice/compilation/spicefile.py +154 -0
  26. spice_lang-0.2.2/spice/compilation/symbol_table.py +104 -0
  27. spice_lang-0.2.2/spice/errors.py +39 -0
  28. spice_lang-0.2.2/spice/lexer/__init__.py +6 -0
  29. spice_lang-0.2.2/spice/lexer/follow_set.py +586 -0
  30. spice_lang-0.2.2/spice/lexer/tokenizer.py +255 -0
  31. spice_lang-0.2.2/spice/lexer/tokens.py +152 -0
  32. spice_lang-0.2.2/spice/parser/__init__.py +8 -0
  33. spice_lang-0.2.2/spice/parser/ast_nodes.py +633 -0
  34. spice_lang-0.2.2/spice/parser/expression_parser.py +741 -0
  35. spice_lang-0.2.2/spice/parser/parser.py +1435 -0
  36. spice_lang-0.2.2/spice/printils.py +34 -0
  37. spice_lang-0.2.2/spice/transformer/__init__.py +5 -0
  38. spice_lang-0.2.2/spice/transformer/transformer.py +1209 -0
  39. spice_lang-0.2.2/spice/utils/__init__.py +3 -0
  40. spice_lang-0.2.2/spice/utils/hashing.py +9 -0
  41. spice_lang-0.2.2/spice_lang.egg-info/PKG-INFO +27 -0
  42. spice_lang-0.2.2/spice_lang.egg-info/SOURCES.txt +60 -0
  43. spice_lang-0.2.2/spice_lang.egg-info/dependency_links.txt +1 -0
  44. spice_lang-0.2.2/spice_lang.egg-info/entry_points.txt +2 -0
  45. spice_lang-0.2.2/spice_lang.egg-info/requires.txt +12 -0
  46. spice_lang-0.2.2/spice_lang.egg-info/top_level.txt +1 -0
  47. spice_lang-0.2.2/tests/test_annotations.py +141 -0
  48. spice_lang-0.2.2/tests/test_class_keywords.py +445 -0
  49. spice_lang-0.2.2/tests/test_constructor_transform.py +162 -0
  50. spice_lang-0.2.2/tests/test_cython_emit.py +222 -0
  51. spice_lang-0.2.2/tests/test_data_classes.py +120 -0
  52. spice_lang-0.2.2/tests/test_enums.py +113 -0
  53. spice_lang-0.2.2/tests/test_final_checker.py +99 -0
  54. spice_lang-0.2.2/tests/test_generic_bounds.py +104 -0
  55. spice_lang-0.2.2/tests/test_generics.py +127 -0
  56. spice_lang-0.2.2/tests/test_lexer.py +329 -0
  57. spice_lang-0.2.2/tests/test_parcer.py +179 -0
  58. spice_lang-0.2.2/tests/test_pipeline_imports.py +57 -0
  59. spice_lang-0.2.2/tests/test_slice_parsing.py +236 -0
  60. spice_lang-0.2.2/tests/test_transformer.py +515 -0
  61. spice_lang-0.2.2/tests/test_type_checker.py +140 -0
  62. spice_lang-0.2.2/tests/testutils.py +206 -0
@@ -0,0 +1,27 @@
1
+ Metadata-Version: 2.4
2
+ Name: spice-lang
3
+ Version: 0.2.2
4
+ Summary: Python but with all the spice
5
+ Author: Reclipse
6
+ Classifier: Development Status :: 3 - Alpha
7
+ Classifier: Intended Audience :: Developers
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.8
10
+ Classifier: Programming Language :: Python :: 3.9
11
+ Classifier: Programming Language :: Python :: 3.10
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Classifier: Operating System :: OS Independent
16
+ Requires-Python: >=3.8
17
+ Requires-Dist: click>=8.0
18
+ Requires-Dist: colorama>=0.4
19
+ Requires-Dist: rites>=0.5.4
20
+ Requires-Dist: multipledispatch>=1.0.0
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest<9,>=8.0; extra == "dev"
23
+ Requires-Dist: pytest-cov>=7.0; extra == "dev"
24
+ Requires-Dist: black>=25.1; extra == "dev"
25
+ Requires-Dist: mypy>=1.0; extra == "dev"
26
+ Requires-Dist: build>=1.0; extra == "dev"
27
+ Requires-Dist: installer>=0.7; extra == "dev"
@@ -0,0 +1,48 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "spice-lang"
7
+ version = "0.2.2"
8
+ description = "Python but with all the spice"
9
+ authors = [
10
+ { name = "Reclipse" }
11
+ ]
12
+ requires-python = ">=3.8"
13
+ dependencies = [
14
+ "click>=8.0",
15
+ "colorama>=0.4",
16
+ "rites>=0.5.4",
17
+ "multipledispatch>=1.0.0",
18
+ ]
19
+
20
+ classifiers = [
21
+ "Development Status :: 3 - Alpha",
22
+ "Intended Audience :: Developers",
23
+ "Programming Language :: Python :: 3",
24
+ "Programming Language :: Python :: 3.8",
25
+ "Programming Language :: Python :: 3.9",
26
+ "Programming Language :: Python :: 3.10",
27
+ "Programming Language :: Python :: 3.11",
28
+ "Programming Language :: Python :: 3.12",
29
+ "Programming Language :: Python :: 3.13",
30
+ "Operating System :: OS Independent"
31
+ ]
32
+
33
+ [project.optional-dependencies]
34
+ dev = [
35
+ "pytest>=8.0,<9",
36
+ "pytest-cov>=7.0",
37
+ "black>=25.1",
38
+ "mypy>=1.0",
39
+ "build>=1.0",
40
+ "installer>=0.7",
41
+ ]
42
+
43
+ [project.scripts]
44
+ spicy = "spice.cli.cli_handler:from_cli"
45
+
46
+ [tool.setuptools.packages.find]
47
+ where = ["."]
48
+ include = ["spice*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,8 @@
1
+ """Spice compiler - A Python superset with static typing features."""
2
+ from importlib.metadata import version
3
+
4
+ from spice import cli, lexer, parser, transformer, errors, printils, compilation
5
+
6
+ __version__ = version("spice-lang")
7
+
8
+ __all__ = ["cli", "lexer", "parser", "transformer", "compilation", "errors", "printils"]
@@ -0,0 +1,15 @@
1
+ """Spice annotation framework: compile-time processors and their registry.
2
+
3
+ `@name` annotations are runtime (emitted as Python decorators by the transformer);
4
+ `@!name` annotations are compile-time and handled here by registered processors.
5
+ """
6
+
7
+ from spice.annotations.base import AnnotationProcessor
8
+ from spice.annotations.registry import register, get_processor, all_processors
9
+
10
+ __all__ = [
11
+ "AnnotationProcessor",
12
+ "register",
13
+ "get_processor",
14
+ "all_processors",
15
+ ]
@@ -0,0 +1,24 @@
1
+ """Base class for compile-time annotation processors."""
2
+
3
+ from abc import ABC, abstractmethod
4
+
5
+
6
+ class AnnotationProcessor(ABC):
7
+ """A compile-time annotation handler.
8
+
9
+ Subclasses set `name` (matched against `@!name`) and optionally `targets`
10
+ (a tuple of AST node types it may annotate; empty means any declaration).
11
+ Annotation arguments bind to `process`'s keyword parameters, decorator-factory
12
+ style: `@!print_on_call(time_format="%H:%M:%S")` -> `process(..., time_format=...)`.
13
+
14
+ `process` mutates `node` in place (and may edit AST fields directly). The
15
+ annotation stage strips the annotation afterward.
16
+ """
17
+
18
+ name: str = ""
19
+ targets: tuple = () # allowed node types; () = any
20
+
21
+ @abstractmethod
22
+ def process(self, node, file, **kwargs) -> None:
23
+ """Apply this annotation to `node`. `file` is the SpiceFile (utilities live there)."""
24
+ raise NotImplementedError
@@ -0,0 +1,6 @@
1
+ """Built-in compile-time annotation processors.
2
+
3
+ Importing this package registers all built-ins as a side effect.
4
+ """
5
+
6
+ from spice.annotations.builtins import print_on_call # noqa: F401 (registers @!print_on_call)
@@ -0,0 +1,34 @@
1
+ """Built-in `@!print_on_call` compile-time annotation.
2
+
3
+ Injects a timestamped log line at the top of the annotated function's body:
4
+
5
+ @!print_on_call(time_format="%H:%M:%S")
6
+ def greet(name: str) -> None { ... }
7
+
8
+ becomes (roughly):
9
+
10
+ import datetime
11
+
12
+ def greet(name: str) -> None:
13
+ print(f"[{datetime.datetime.now().strftime('%H:%M:%S')}] greet called")
14
+ ...
15
+ """
16
+
17
+ from spice.annotations import AnnotationProcessor, register
18
+ from spice.parser.ast_nodes import FunctionDeclaration
19
+
20
+
21
+ @register
22
+ class PrintOnCall(AnnotationProcessor):
23
+ name = "print_on_call"
24
+ targets = (FunctionDeclaration,)
25
+
26
+ def process(self, node, file, *, time_format: str = "%H:%M:%S") -> None:
27
+ if node.body is None:
28
+ node.body = []
29
+
30
+ file.ensure_import("datetime")
31
+
32
+ timestamp = f"datetime.datetime.now().strftime({time_format!r})"
33
+ code = f'print(f"[{{{timestamp}}}] {node.name} called")'
34
+ node.body.insert(0, file.raw_python(code))
@@ -0,0 +1,26 @@
1
+ """Registry of compile-time annotation processors, keyed by name."""
2
+
3
+ from spice.annotations.base import AnnotationProcessor
4
+
5
+ _REGISTRY: dict[str, AnnotationProcessor] = {}
6
+
7
+
8
+ def register(cls):
9
+ """Class decorator: instantiate the processor and register it by `name`."""
10
+ instance = cls()
11
+ if not getattr(instance, "name", ""):
12
+ raise ValueError(f"Annotation processor {cls.__name__} must define a non-empty 'name'")
13
+ if instance.name in _REGISTRY:
14
+ raise ValueError(f"Duplicate annotation processor registered for '@!{instance.name}'")
15
+ _REGISTRY[instance.name] = instance
16
+ return cls
17
+
18
+
19
+ def get_processor(name: str):
20
+ """Return the registered processor for `name`, or None."""
21
+ return _REGISTRY.get(name)
22
+
23
+
24
+ def all_processors() -> dict:
25
+ """A copy of the current registry."""
26
+ return dict(_REGISTRY)
@@ -0,0 +1,3 @@
1
+ """CLI module for Spice language."""
2
+
3
+ __all__ = []
@@ -0,0 +1,38 @@
1
+ from spice.printils import spice_compiler_log, pipeline_log, spam_console
2
+ from spice.compilation import BuildFlags, SpiceFile, SpicePipeline
3
+
4
+ from typing import Optional
5
+ from pathlib import Path
6
+
7
+ import click
8
+
9
+ @click.command()
10
+ @click.argument('source', type=click.Path(exists=True))
11
+ @click.option('-o', '--output', type=click.Path(), help='Output file (default: <source>.py)')
12
+ @click.option('-e', '--emit', type=click.Choice(['py', 'pyx', 'exe']), default='py', help='Compilation target (default: py)')
13
+ @click.option('-k', '--keep-intermidiates', is_flag=True, help='Keep intermidiates generated during compilation (only applies to exe emit)')
14
+ @click.option('-c', '--check', is_flag=True, help='Check syntax without generating output')
15
+ @click.option('-w', '--watch', is_flag=True, help='Watch file for changes. This option disables verbosity.')
16
+ @click.option('-v', '--verbose', is_flag=True, help='Verbose output')
17
+ @click.option('--runtime-checks', is_flag=True, help='Add runtime type checking to output')
18
+ @click.version_option(package_name='spice-lang', prog_name='spicy')
19
+ def from_cli(source: str, output: Optional[str], emit: str, keep_intermidiates: bool, check: bool, watch: bool, verbose: bool, runtime_checks: bool):
20
+ """Compile Spice (.spc) files to Python, Cython, or standalone executables."""
21
+ flags: BuildFlags = BuildFlags(
22
+ source=Path(source),
23
+ output=Path(output) if output else None,
24
+ emit=emit,
25
+ keep_intermidiates=keep_intermidiates,
26
+ check=check,
27
+ watch=watch,
28
+ verbose=verbose,
29
+ runtime_checks=runtime_checks
30
+ )
31
+ spam_console(flags.verbose)
32
+
33
+ spice_compiler_log.custom("spice", f"Compiling from entry point: {flags.source.resolve().as_posix()}")
34
+
35
+ spice_tree: SpiceFile = SpicePipeline.walk(flags.source, None, flags)
36
+ SpicePipeline.verify_and_write(spice_tree, flags)
37
+
38
+ spice_compiler_log.custom("spice", "Compilation finished successfully.")
@@ -0,0 +1,8 @@
1
+ """Compilation module for Spice"""
2
+
3
+ from spice.compilation.build_flags import BuildFlags
4
+ from spice.compilation.spicefile import SpiceFile
5
+ from spice.compilation.pipeline import SpicePipeline
6
+ from spice.compilation import checks
7
+
8
+ __all__ = ["checks", "BuildFlags", "SpiceFile", "SpicePipeline"]
@@ -0,0 +1,105 @@
1
+ """Build configuration for the Spice compiler."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any, Optional
7
+
8
+
9
+ def _core_field(key: str) -> property:
10
+ """Build a get/set wrapper (property) for a core field backed by `_data`."""
11
+ def getter(self: "BuildFlags") -> Any:
12
+ return self._data.get(key)
13
+
14
+ def setter(self: "BuildFlags", value: Any) -> None:
15
+ self._data[key] = value
16
+
17
+ getter.__name__ = key
18
+ return property(getter, setter, doc=f"Core build flag '{key}'.")
19
+
20
+
21
+ class BuildFlags:
22
+ """Compiler build options.
23
+
24
+ Core fields:
25
+ source: Path to the source .spc file or a directory with __main__.spc
26
+ output: Output directory or file path
27
+ emit: Compilation target ('py', 'pyx', or 'exe')
28
+ keep_intermediates: Keep intermediates generated during exe compilation
29
+ check: Syntax validation without code generation
30
+ watch: File watching mode
31
+ verbose: Detailed logging of pipeline stages
32
+ runtime_checks: Inject runtime type checking decorators
33
+
34
+ Extra keys (for plugins/tools) live in the same dict and are reached with
35
+ get()/set(), item access (flags["key"]) or as_dict().
36
+ """
37
+
38
+ CORE_DEFAULTS: dict[str, Any] = {
39
+ "source": None,
40
+ "output": None,
41
+ "emit": "py",
42
+ "keep_intermediates": False,
43
+ "check": False,
44
+ "watch": False,
45
+ "verbose": False,
46
+ "runtime_checks": False,
47
+ }
48
+
49
+ def __init__(
50
+ self,
51
+ source: Path,
52
+ output: Optional[Path] = None,
53
+ emit: str = "py",
54
+ keep_intermidiates: bool = False,
55
+ check: bool = False,
56
+ watch: bool = False,
57
+ verbose: bool = False,
58
+ runtime_checks: bool = False,
59
+ **extra: Any,
60
+ ) -> None:
61
+ self._data: dict[str, Any] = dict(self.CORE_DEFAULTS)
62
+ self._data.update({
63
+ "source": source,
64
+ "output": output,
65
+ "emit": emit,
66
+ "keep_intermediates": keep_intermidiates,
67
+ "check": check,
68
+ "watch": watch,
69
+ "verbose": verbose,
70
+ "runtime_checks": runtime_checks,
71
+ })
72
+
73
+ self._data.update(extra)
74
+
75
+ source = _core_field("source")
76
+ output = _core_field("output")
77
+ emit = _core_field("emit")
78
+ keep_intermediates = _core_field("keep_intermediates")
79
+ check = _core_field("check")
80
+ watch = _core_field("watch")
81
+ verbose = _core_field("verbose")
82
+ runtime_checks = _core_field("runtime_checks")
83
+
84
+ def get(self, key: str, default: Any = None) -> Any:
85
+ return self._data.get(key, default)
86
+
87
+ def set(self, key: str, value: Any) -> "BuildFlags":
88
+ self._data[key] = value
89
+ return self
90
+
91
+ def __getitem__(self, key: str) -> Any:
92
+ return self._data[key]
93
+
94
+ def __setitem__(self, key: str, value: Any) -> None:
95
+ self._data[key] = value
96
+
97
+ def __contains__(self, key: str) -> bool:
98
+ return key in self._data
99
+
100
+ def as_dict(self) -> dict[str, Any]:
101
+ """A shallow copy of the backing dict (core fields + extras)."""
102
+ return dict(self._data)
103
+
104
+ def __repr__(self) -> str:
105
+ return f"BuildFlags({self._data!r})"
@@ -0,0 +1,22 @@
1
+ """Compile time checks for Spice source code."""
2
+
3
+ from spice.compilation.checks.final_checker import FinalChecker
4
+ from spice.compilation.checks.interface_checker import InterfaceChecker, CheckError
5
+ from spice.compilation.checks.method_overload_resolver import MethodOverloadResolver
6
+ from spice.compilation.checks.compile_time_check import CompileTimeCheck
7
+ from spice.compilation.checks.symbol_table_builder import SymbolTableBuilder
8
+ from spice.compilation.checks.type_checker import TypeChecker
9
+ from spice.compilation.checks.generic_bound_checker import GenericBoundChecker
10
+ from spice.compilation.checks.annotation_stage import AnnotationStage
11
+
12
+ __all__ = [
13
+ "CheckError",
14
+ "FinalChecker",
15
+ "InterfaceChecker",
16
+ "MethodOverloadResolver",
17
+ "SymbolTableBuilder",
18
+ "TypeChecker",
19
+ "GenericBoundChecker",
20
+ "CompileTimeCheck",
21
+ "AnnotationStage",
22
+ ]
@@ -0,0 +1,87 @@
1
+ """Annotation lowering stage.
2
+
3
+ Runs compile-time (`@!`) annotation processors over the AST, strips the
4
+ processed annotations, and leaves runtime (`@`) annotations in place for the
5
+ transformer to emit as decorators. Implemented as a `CompileTimeCheck` so it
6
+ plugs into the pipeline and reports fatal errors the usual way.
7
+ """
8
+
9
+ import inspect
10
+
11
+ from spice.compilation.checks.compile_time_check import CompileTimeCheck
12
+ from spice.parser.ast_nodes import LiteralExpression
13
+ from spice.annotations import get_processor
14
+ import spice.annotations.builtins
15
+
16
+
17
+ class AnnotationStage(CompileTimeCheck):
18
+ def __init__(self):
19
+ self.errors = []
20
+
21
+ def check(self, file) -> bool:
22
+ self.errors = []
23
+
24
+ # Comp time processors are able to modify the code so we need to materialize nodes first
25
+ for node in list(file.walk()):
26
+ if getattr(node, "annotations", None):
27
+ self._process_node(node, file)
28
+
29
+ self.errors.extend(getattr(file, "diagnostics", []))
30
+ return not self.errors
31
+
32
+ def _process_node(self, node, file) -> None:
33
+ remaining = []
34
+ for ann in node.annotations:
35
+ if ann.retention != "compile_time":
36
+ remaining.append(ann) # runtime: left for the transformer
37
+ continue
38
+ self._apply(ann, node, file) # compile-time: applied, then stripped
39
+ node.annotations = remaining
40
+
41
+ def _apply(self, ann, node, file) -> None:
42
+ proc = get_processor(ann.name)
43
+ if proc is None:
44
+ self.errors.append(f"Unknown compile-time annotation '@!{ann.name}' (line {ann.line})")
45
+ return
46
+
47
+ if proc.targets and not isinstance(node, proc.targets):
48
+ allowed = ", ".join(t.__name__ for t in proc.targets)
49
+ self.errors.append(
50
+ f"'@!{ann.name}' cannot annotate {type(node).__name__}; allowed: {allowed} (line {ann.line})"
51
+ )
52
+ return
53
+
54
+ try:
55
+ values = [self._literal(a) for a in ann.args]
56
+ kwargs = {key: self._literal(value) for key, value in ann.kwargs.items()}
57
+ except TypeError as exc:
58
+ self.errors.append(f"'@!{ann.name}': {exc} (line {ann.line})")
59
+ return
60
+
61
+ # Validate arity / names against process(node, file, **bound) before any side effects.
62
+ try:
63
+ inspect.signature(proc.process).bind(node, file, *values, **kwargs)
64
+ except TypeError as exc:
65
+ self.errors.append(f"'@!{ann.name}': {exc} (line {ann.line})")
66
+ return
67
+
68
+ proc.process(node, file, *values, **kwargs)
69
+
70
+ def _literal(self, expr):
71
+ """Evaluate a compile-time-literal argument expression to a Python value."""
72
+ if not isinstance(expr, LiteralExpression):
73
+ raise TypeError(f"argument must be a compile-time literal, got {type(expr).__name__}")
74
+
75
+ lt = expr.literal_type
76
+ value = expr.value
77
+ if lt == "number":
78
+ text = str(value)
79
+ return float(text) if ("." in text or "e" in text.lower()) else int(text)
80
+ if lt in ("string", "fstring"):
81
+ s = str(value)
82
+ if len(s) >= 2 and s[0] == s[-1] and s[0] in "\"'":
83
+ s = s[1:-1]
84
+ return s
85
+ if lt == "boolean":
86
+ return bool(value)
87
+ return value
@@ -0,0 +1,16 @@
1
+ from abc import ABC, abstractmethod
2
+ from spice.compilation.spicefile import SpiceFile
3
+
4
+ class CompileTimeCheck(ABC):
5
+ @abstractmethod
6
+ def check(self, file: SpiceFile) -> bool:
7
+ """Perform the compile-time check on the given SpiceFile.
8
+
9
+ Args:
10
+ file (SpiceFile): The Spice file to check.
11
+
12
+ Returns:
13
+ bool: True if the check passes, False otherwise.
14
+ """
15
+ pass
16
+
@@ -0,0 +1,189 @@
1
+ from dataclasses import fields, is_dataclass
2
+ from typing import Dict, List, Optional, Set, TYPE_CHECKING, Union
3
+
4
+ from spice.parser.ast_nodes import (
5
+ ASTNode,
6
+ AssignmentExpression,
7
+ ClassDeclaration,
8
+ ExpressionStatement,
9
+ FinalDeclaration,
10
+ FunctionDeclaration,
11
+ IdentifierExpression,
12
+ )
13
+ from spice.compilation.checks.compile_time_check import CompileTimeCheck
14
+ from spice.compilation.checks.interface_checker import CheckError
15
+
16
+ if TYPE_CHECKING:
17
+ from spice.compilation.spicefile import SpiceFile
18
+
19
+ class FinalChecker(CompileTimeCheck):
20
+ """Compile-time checker for final variable reassignments."""
21
+
22
+ def __init__(self):
23
+ self._reset_state()
24
+
25
+ def _reset_state(self):
26
+ self.final_variables: Dict[str, Set[str]] = {'global': set()}
27
+ self.current_scope = 'global'
28
+ self.errors: List[Union[str, CheckError]] = []
29
+ self.class_nodes: Dict[str, ClassDeclaration] = {}
30
+ self.final_methods_by_class: Dict[str, Dict[str, FunctionDeclaration]] = {}
31
+
32
+ def check(self, file: "SpiceFile") -> bool:
33
+ self._reset_state()
34
+ self._collect_class_metadata(file.ast.body)
35
+ for node in file.ast.body:
36
+ self._visit_node(node)
37
+ return not self.errors
38
+
39
+
40
+ def enter_scope(self, scope_name: str):
41
+ """Enter a new scope (function/class)."""
42
+ self.current_scope = scope_name
43
+ if scope_name not in self.final_variables:
44
+ self.final_variables[scope_name] = set()
45
+
46
+ def exit_scope(self):
47
+ """Exit current scope."""
48
+ self.current_scope = 'global'
49
+
50
+ def register_final(self, var_name: str):
51
+ """Register a variable as final in current scope."""
52
+ if self.current_scope not in self.final_variables:
53
+ self.final_variables[self.current_scope] = set()
54
+ self.final_variables[self.current_scope].add(var_name)
55
+
56
+ def check_assignment(self, var_name: str, line: int = 0, column: int = 0):
57
+ """Check if assignment to variable is allowed."""
58
+ scope_vars = self.final_variables.get(self.current_scope, set())
59
+ global_vars = self.final_variables.get('global', set())
60
+
61
+ if var_name in scope_vars or var_name in global_vars:
62
+ self.errors.append(CheckError(
63
+ message=f"Cannot reassign final variable '{var_name}'",
64
+ line=line,
65
+ column=column
66
+ ))
67
+ return False
68
+ return True
69
+
70
+ def _visit_node(self, node):
71
+ """Visit a node and check for violations."""
72
+ if isinstance(node, FinalDeclaration):
73
+ if isinstance(node.target, IdentifierExpression):
74
+ self.register_final(node.target.name)
75
+
76
+ elif isinstance(node, ExpressionStatement):
77
+ self._visit_expression(node.expression)
78
+ elif isinstance(node, AssignmentExpression):
79
+ self._handle_assignment(node)
80
+ elif isinstance(node, FunctionDeclaration):
81
+ old_scope = self.current_scope
82
+ self.enter_scope(node.name)
83
+ for stmt in node.body:
84
+ self._visit_node(stmt)
85
+ self.current_scope = old_scope
86
+
87
+ elif isinstance(node, ClassDeclaration):
88
+ old_scope = self.current_scope
89
+ self.enter_scope(node.name)
90
+ self._check_final_method_overrides(node)
91
+ for member in node.body:
92
+ self._visit_node(member)
93
+ self.current_scope = old_scope
94
+
95
+ if hasattr(node, 'body') and isinstance(node.body, list):
96
+ for child in node.body:
97
+ self._visit_node(child)
98
+
99
+ def _visit_expression(self, expr: Optional[ASTNode]):
100
+ """Traverse expressions searching for assignments."""
101
+ if expr is None:
102
+ return
103
+
104
+ if isinstance(expr, AssignmentExpression):
105
+ self._handle_assignment(expr)
106
+
107
+ if is_dataclass(expr):
108
+ for field in fields(expr):
109
+ value = getattr(expr, field.name)
110
+ self._visit_expression_field(value)
111
+
112
+ def _visit_expression_field(self, value):
113
+ if isinstance(value, list):
114
+ for item in value:
115
+ if isinstance(item, ASTNode):
116
+ self._visit_expression(item)
117
+ elif isinstance(value, ASTNode):
118
+ self._visit_expression(value)
119
+
120
+ def _handle_assignment(self, node: AssignmentExpression):
121
+ if isinstance(node.target, IdentifierExpression):
122
+ self.check_assignment(node.target.name, node.line, node.column)
123
+ self._visit_expression(node.value)
124
+
125
+ def _collect_class_metadata(self, nodes: List[ASTNode]):
126
+ """Collect class declarations and their final methods for later checks."""
127
+ for node in nodes:
128
+ if isinstance(node, ClassDeclaration):
129
+ self.class_nodes[node.name] = node
130
+
131
+ final_methods = {
132
+ member.name: member
133
+ for member in node.body
134
+ if isinstance(member, FunctionDeclaration) and member.is_final
135
+ }
136
+ if final_methods:
137
+ self.final_methods_by_class[node.name] = final_methods
138
+
139
+ self._collect_class_metadata(node.body)
140
+ elif hasattr(node, 'body') and isinstance(node.body, list):
141
+ self._collect_class_metadata(node.body)
142
+
143
+ def _check_final_method_overrides(self, class_node: ClassDeclaration):
144
+ """Ensure subclasses do not override final methods declared in parents."""
145
+ inherited_final_methods = self._collect_inherited_final_methods(class_node)
146
+ if not inherited_final_methods:
147
+ return
148
+
149
+ for member in class_node.body:
150
+ if isinstance(member, FunctionDeclaration) and member.name in inherited_final_methods:
151
+ base_name = inherited_final_methods[member.name]
152
+ self.errors.append(CheckError(
153
+ message=f"Class '{class_node.name}' cannot override final method '{member.name}' defined in '{base_name}'",
154
+ line=member.line,
155
+ column=member.column
156
+ ))
157
+
158
+ def _collect_inherited_final_methods(self, class_node: ClassDeclaration) -> Dict[str, str]:
159
+ """Gather final methods from all parent classes."""
160
+ inherited: Dict[str, str] = {}
161
+
162
+ for base_name in class_node.bases:
163
+ base_methods = self._collect_final_methods_from_base(base_name, set())
164
+ for method_name, origin in base_methods.items():
165
+ inherited.setdefault(method_name, origin)
166
+
167
+ return inherited
168
+
169
+ def _collect_final_methods_from_base(self, base_name: str, visited: Set[str]) -> Dict[str, str]:
170
+ """Collect final methods from the given base class and its parents."""
171
+ if base_name in visited:
172
+ return {}
173
+
174
+ visited.add(base_name)
175
+ methods: Dict[str, str] = {
176
+ method_name: base_name
177
+ for method_name in self.final_methods_by_class.get(base_name, {})
178
+ }
179
+
180
+ base_node = self.class_nodes.get(base_name)
181
+ if not base_node:
182
+ return methods
183
+
184
+ for ancestor in base_node.bases:
185
+ ancestor_methods = self._collect_final_methods_from_base(ancestor, set(visited))
186
+ for method_name, origin in ancestor_methods.items():
187
+ methods.setdefault(method_name, origin)
188
+
189
+ return methods