pyscriptc 0.1.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.
Files changed (54) hide show
  1. pyscriptc/__init__.py +3 -0
  2. pyscriptc/__main__.py +3 -0
  3. pyscriptc/_posthog_token.py +1 -0
  4. pyscriptc/builtins.py +156 -0
  5. pyscriptc/canonical.py +167 -0
  6. pyscriptc/check.py +49 -0
  7. pyscriptc/cli.py +271 -0
  8. pyscriptc/coverage.py +388 -0
  9. pyscriptc/debug.py +120 -0
  10. pyscriptc/diagnostics.py +179 -0
  11. pyscriptc/errors.py +56 -0
  12. pyscriptc/ir.py +365 -0
  13. pyscriptc/llvm.py +2129 -0
  14. pyscriptc/log.py +127 -0
  15. pyscriptc/lower/__init__.py +1071 -0
  16. pyscriptc/lower/_calls.py +1499 -0
  17. pyscriptc/lower/_classes.py +969 -0
  18. pyscriptc/lower/_core.py +301 -0
  19. pyscriptc/lower/_expr.py +886 -0
  20. pyscriptc/lower/_stdlib.py +491 -0
  21. pyscriptc/lower/_stdlib_shared.py +93 -0
  22. pyscriptc/lower/_stmt.py +1173 -0
  23. pyscriptc/package.py +249 -0
  24. pyscriptc/restlower.py +1008 -0
  25. pyscriptc/runtime/ps_argv.c +28 -0
  26. pyscriptc/runtime/ps_b64.c +168 -0
  27. pyscriptc/runtime/ps_coll.c +133 -0
  28. pyscriptc/runtime/ps_dt.c +388 -0
  29. pyscriptc/runtime/ps_hash.c +411 -0
  30. pyscriptc/runtime/ps_help.c +219 -0
  31. pyscriptc/runtime/ps_hmac.c +120 -0
  32. pyscriptc/runtime/ps_io.c +198 -0
  33. pyscriptc/runtime/ps_island.c +272 -0
  34. pyscriptc/runtime/ps_json.c +402 -0
  35. pyscriptc/runtime/ps_mix.c +47 -0
  36. pyscriptc/runtime/ps_more.c +1089 -0
  37. pyscriptc/runtime/ps_osmore.c +447 -0
  38. pyscriptc/runtime/ps_path.c +547 -0
  39. pyscriptc/runtime/ps_re.c +1102 -0
  40. pyscriptc/runtime/ps_rt.c +1175 -0
  41. pyscriptc/runtime/ps_rt.h +189 -0
  42. pyscriptc/runtime/ps_struct.c +291 -0
  43. pyscriptc/runtime/ps_time.c +207 -0
  44. pyscriptc/runtime/ps_unicode.c +1521 -0
  45. pyscriptc/runtime/ps_url.c +327 -0
  46. pyscriptc/stdlib_queue.py +191 -0
  47. pyscriptc/stdmore.py +1596 -0
  48. pyscriptc/telemetry.py +219 -0
  49. pyscriptc-0.1.0.dist-info/METADATA +187 -0
  50. pyscriptc-0.1.0.dist-info/RECORD +54 -0
  51. pyscriptc-0.1.0.dist-info/WHEEL +5 -0
  52. pyscriptc-0.1.0.dist-info/entry_points.txt +2 -0
  53. pyscriptc-0.1.0.dist-info/licenses/LICENSE +201 -0
  54. pyscriptc-0.1.0.dist-info/top_level.txt +1 -0
pyscriptc/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Spike compiler: CPython ast to a Python typed IR to LLVM."""
2
+
3
+ __version__ = "0.1.0"
pyscriptc/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from pyscriptc.cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ TOKEN = "phc_yqbYPqN9SbrYLd4A3HKwoRFqzfe9Q5LiMbKg3QBrYKVc"
pyscriptc/builtins.py ADDED
@@ -0,0 +1,156 @@
1
+ """Closed builtin and method tables.
2
+
3
+ A name is lowered only when it is listed here. `len` of `str` and `len` of a
4
+ list are different operations; both are listed. Anything else stays a fence.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ # Codes match ps_rt.c. Exception is the wildcard handler and a raise target.
12
+ EXCEPTIONS: dict[str, int] = {
13
+ "ValueError": 1,
14
+ "IndexError": 2,
15
+ "KeyError": 3,
16
+ "OverflowError": 4,
17
+ "ZeroDivisionError": 5,
18
+ "RuntimeError": 6,
19
+ "Exception": 7,
20
+ }
21
+
22
+ # User exception classes take codes from here up; matches PS_USER_BASE in
23
+ # ps_rt.h. Exception handlers treat every code at or above it as an
24
+ # Exception descendant, since all user exceptions inherit a lowered builtin.
25
+ USER_EXC_BASE = 8
26
+
27
+ # params are type kinds. return is a type kind, or "self" for the receiver's type.
28
+ STR_METHODS: dict[str, dict[str, Any]] = {
29
+ "strip": {"params": [], "return": "self"},
30
+ "lstrip": {"params": [], "return": "self"},
31
+ "rstrip": {"params": [], "return": "self"},
32
+ "upper": {"params": [], "return": "self"},
33
+ "lower": {"params": [], "return": "self"},
34
+ "startswith": {"params": ["self"], "return": "bool"},
35
+ "endswith": {"params": ["self"], "return": "bool"},
36
+ "find": {"params": ["self"], "return": "int"},
37
+ "replace": {"params": ["self", "self"], "return": "self"},
38
+ "count": {"params": ["self"], "return": "int"},
39
+ "index": {"params": ["self"], "return": "int"},
40
+ "rfind": {"params": ["self"], "return": "int"},
41
+ "isdigit": {"params": [], "return": "bool"},
42
+ "isalpha": {"params": [], "return": "bool"},
43
+ "isalnum": {"params": [], "return": "bool"},
44
+ "isspace": {"params": [], "return": "bool"},
45
+ "isupper": {"params": [], "return": "bool"},
46
+ "islower": {"params": [], "return": "bool"},
47
+ "title": {"params": [], "return": "self"},
48
+ "capitalize": {"params": [], "return": "self"},
49
+ # casefold is str-only in CPython; the "kinds" gate keeps bytes.casefold fenced.
50
+ "casefold": {"params": [], "return": "self", "kinds": {"str"}},
51
+ "swapcase": {"params": [], "return": "self"},
52
+ "zfill": {"params": ["int"], "return": "self"},
53
+ "removeprefix": {"params": ["self"], "return": "self"},
54
+ "removesuffix": {"params": ["self"], "return": "self"},
55
+ "splitlines": {"params": [], "return": "self_list"},
56
+ }
57
+
58
+ LEN_KINDS = {"str", "bytes", "list", "dict"}
59
+
60
+
61
+ def _len_ok(typ: dict[str, Any]) -> bool:
62
+ if typ["kind"] in LEN_KINDS:
63
+ return True
64
+ return typ["kind"] == "class" and typ.get("name") == "Deque"
65
+
66
+
67
+ def builtin_spec(name: str, arg_types: list[dict[str, Any]]) -> dict[str, Any] | None:
68
+ """Return the result type of a builtin call, or None when it is not lowered."""
69
+
70
+ if name == "len" and len(arg_types) == 1 and _len_ok(arg_types[0]):
71
+ return {"kind": "int"}
72
+ if name == "int" and len(arg_types) == 1 and arg_types[0]["kind"] in {"float", "bytes", "str"}:
73
+ return {"kind": "int"}
74
+ if name == "float" and len(arg_types) == 1 and arg_types[0]["kind"] in {"int", "str"}:
75
+ return {"kind": "float"}
76
+ if (
77
+ name == "str"
78
+ and len(arg_types) == 1
79
+ and arg_types[0]["kind"]
80
+ in {
81
+ "str",
82
+ "int",
83
+ "float",
84
+ "bool",
85
+ "bytes",
86
+ "none",
87
+ }
88
+ ):
89
+ return {"kind": "str"}
90
+ if name == "str" and len(arg_types) == 1 and arg_types[0]["kind"] == "class" and arg_types[0].get("name") == "Path":
91
+ return {"kind": "str"}
92
+ return None
93
+
94
+
95
+ def method_spec(receiver: dict[str, Any], name: str, arg_types: list[dict[str, Any]]) -> dict[str, Any] | None:
96
+ if receiver["kind"] not in {"str", "bytes"}:
97
+ return None
98
+ if name == "encode":
99
+ # One optional str encoding argument; the value is checked in _call_attr.
100
+ if receiver["kind"] != "str" or len(arg_types) > 1:
101
+ return None
102
+ if arg_types and arg_types[0]["kind"] != "str":
103
+ return None
104
+ return {"kind": "bytes"}
105
+ if name == "decode":
106
+ if receiver["kind"] != "bytes" or len(arg_types) > 1:
107
+ return None
108
+ if arg_types and arg_types[0]["kind"] != "str":
109
+ return None
110
+ return {"kind": "str"}
111
+ if name in {"split", "rsplit"}:
112
+ # split([sep [, maxsplit]]); sep None is not a lowered type, so the
113
+ # whitespace form stays the 0-arg call.
114
+ if len(arg_types) > 2:
115
+ return None
116
+ if len(arg_types) >= 1 and arg_types[0]["kind"] != receiver["kind"]:
117
+ return None
118
+ if len(arg_types) == 2 and arg_types[1]["kind"] != "int":
119
+ return None
120
+ return {"kind": "list", "elem": {"kind": receiver["kind"]}}
121
+ if name in {"ljust", "rjust", "center"}:
122
+ if not 1 <= len(arg_types) <= 2:
123
+ return None
124
+ if arg_types[0]["kind"] != "int":
125
+ return None
126
+ if len(arg_types) == 2 and arg_types[1]["kind"] != receiver["kind"]:
127
+ return None
128
+ return {"kind": receiver["kind"]}
129
+ if name == "expandtabs":
130
+ if len(arg_types) > 1 or (arg_types and arg_types[0]["kind"] != "int"):
131
+ return None
132
+ return {"kind": receiver["kind"]}
133
+ if name == "join":
134
+ if len(arg_types) != 1 or arg_types[0]["kind"] != "list":
135
+ return None
136
+ if arg_types[0]["elem"]["kind"] != receiver["kind"]:
137
+ return None
138
+ return {"kind": receiver["kind"]}
139
+ if name not in STR_METHODS:
140
+ return None
141
+ spec = STR_METHODS[name]
142
+ if receiver["kind"] not in spec.get("kinds", {"str", "bytes"}):
143
+ return None
144
+ if len(arg_types) != len(spec["params"]):
145
+ return None
146
+ for got, want in zip(arg_types, spec["params"], strict=True):
147
+ if want == "self":
148
+ if got["kind"] != receiver["kind"]:
149
+ return None
150
+ elif got["kind"] != want:
151
+ return None
152
+ if spec["return"] == "self":
153
+ return {"kind": receiver["kind"]}
154
+ if spec["return"] == "self_list":
155
+ return {"kind": "list", "elem": {"kind": receiver["kind"]}}
156
+ return {"kind": spec["return"]}
pyscriptc/canonical.py ADDED
@@ -0,0 +1,167 @@
1
+ """Canonical form of the JSON IR and its stable hash.
2
+
3
+ Probes compare compiler runs across invocations, so the IR they digest must
4
+ not carry anything incidental: spans name the invocation directory, the
5
+ cpython stamp tracks the interpreter rather than the compiler, and package
6
+ merge notes are stderr material. The rules and the full key vocabulary live
7
+ in docs/ir-canonicity.md; this module is that document in code.
8
+
9
+ The hash is schema-versioned (the sccache FORMAT_VERSION pattern): a change
10
+ to the canonicalization rules rekeys every digest instead of masquerading as
11
+ a compiler change.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import copy
17
+ import hashlib
18
+ import json
19
+ import os
20
+ import sys
21
+ from functools import lru_cache
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ CANONICAL_SCHEMA_VERSION = "canonical-schema/2"
26
+
27
+ # Schema/2: environment-derived absolute paths reaching IR string constants
28
+ # (stdmore.py reads sys.prefix / sys.executable at compile time) are replaced
29
+ # with this placeholder so the digest cannot depend on the checkout or venv
30
+ # location. Decision 2026-10-16.
31
+ _PATH_PLACEHOLDER = "<PATH>"
32
+
33
+ # Keys removed at every nesting depth. `span` rides on functions, classes,
34
+ # statements, expressions and fence records; the rest are module level only.
35
+ _STRIP_KEYS = frozenset({"span", "sourceFile", "cpython", "_notes"})
36
+
37
+
38
+ def canonicalize(module: dict[str, Any]) -> dict[str, Any]:
39
+ """Return a canonical deep copy: incidental keys stripped, orders normalized.
40
+
41
+ - every `span`, plus top-level `sourceFile`, `cpython`, `_notes`, gone;
42
+ - every substring of an environment absolute path (sys.executable,
43
+ sys._base_executable, sys.exec_prefix, sys.prefix, cwd, the pyscriptc
44
+ checkout root) in any string value is replaced by `<PATH>`; longest
45
+ first so an executable path is consumed before its prefix runs;
46
+ - `classes` sorted by `name` (synthetic class order leaks a set iteration
47
+ order at lower.py:607);
48
+ - every try-handler's `codes` list sorted (same leak via self.classes);
49
+ - `fences` sorted by (code, message, function or "", span.line, span.col),
50
+ computed before spans are stripped because `function` may be None.
51
+
52
+ Idempotent: canonicalize(canonicalize(x)) == canonicalize(x). The input
53
+ dict is never mutated.
54
+ """
55
+ out = _strip(module)
56
+ classes = out.get("classes")
57
+ if isinstance(classes, list):
58
+ out["classes"] = sorted(classes, key=lambda cls: cls.get("name") or "")
59
+ for stmt in _walk_stmts(out):
60
+ for handler in stmt.get("handlers", ()):
61
+ if isinstance(handler, dict) and isinstance(handler.get("codes"), list):
62
+ handler["codes"] = sorted(handler["codes"])
63
+ fences = out.get("fences")
64
+ if isinstance(fences, list):
65
+ out["fences"] = sorted(
66
+ fences,
67
+ key=lambda fence: (
68
+ fence.get("code") or "",
69
+ fence.get("message") or "",
70
+ fence.get("function") or "",
71
+ fence.get("span", {}).get("line", 0),
72
+ fence.get("span", {}).get("col", 0),
73
+ ),
74
+ )
75
+ return _scrub_paths(out)
76
+
77
+
78
+ def hash_module(module: dict[str, Any]) -> str:
79
+ """SHA-256 hex of the schema-versioned canonical form of a module IR dict."""
80
+ canonical = {
81
+ "schema": CANONICAL_SCHEMA_VERSION,
82
+ "ir": canonicalize(module),
83
+ }
84
+ # allow_nan=False on purpose: a NaN reaching IR means a compiler bug and
85
+ # should surface here instead of silently hashing into a digest.
86
+ payload = json.dumps(canonical, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False)
87
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
88
+
89
+
90
+ @lru_cache(maxsize=1)
91
+ def _env_paths() -> tuple[str, ...]:
92
+ """Every absolute path this environment could leak into IR strings.
93
+
94
+ Realpath and as-given variants both (macOS /tmp is a symlink), longest
95
+ first so a path nested under another is replaced before its container.
96
+ Cached: sys.prefix etc. cannot change inside one process.
97
+ """
98
+
99
+ roots: list[str] = []
100
+ for raw in (
101
+ getattr(sys, "executable", ""),
102
+ getattr(sys, "_base_executable", ""),
103
+ getattr(sys, "exec_prefix", ""),
104
+ getattr(sys, "prefix", ""),
105
+ os.getcwd(),
106
+ _checkout_root(),
107
+ ):
108
+ if not raw:
109
+ continue
110
+ roots.append(os.path.normpath(raw))
111
+ real = os.path.realpath(raw)
112
+ if real != roots[-1]:
113
+ roots.append(real)
114
+ # Drop paths contained in a longer candidate: replacing the container
115
+ # first would strand the remainder of the shorter one as plain text.
116
+ return tuple(sorted(set(roots), key=len, reverse=True))
117
+
118
+
119
+ def _checkout_root() -> str:
120
+ """Repository root found by walking up from this file to a pyproject.toml."""
121
+
122
+ for parent in Path(__file__).resolve().parents:
123
+ if (parent / "pyproject.toml").is_file():
124
+ return str(parent)
125
+ return ""
126
+
127
+
128
+ def _scrub_paths(value: Any) -> Any:
129
+ """Replace environment path substrings in every string value (keys are
130
+ structural: the IR key vocabulary is fixed and never carries paths)."""
131
+
132
+ if isinstance(value, dict):
133
+ return {k: _scrub_paths(v) for k, v in value.items()}
134
+ if isinstance(value, list):
135
+ return [_scrub_paths(item) for item in value]
136
+ if isinstance(value, str):
137
+ for path in _env_paths():
138
+ if path in value:
139
+ value = value.replace(path, _PATH_PLACEHOLDER)
140
+ return value
141
+ return value
142
+
143
+
144
+ def _strip(value: Any) -> Any:
145
+ """Deep copy `value` dropping every _STRIP_KEYS member at any depth."""
146
+ if isinstance(value, dict):
147
+ return {k: _strip(v) for k, v in value.items() if k not in _STRIP_KEYS}
148
+ if isinstance(value, list):
149
+ return [_strip(item) for item in value]
150
+ if isinstance(value, tuple):
151
+ return [_strip(item) for item in value]
152
+ return copy.copy(value)
153
+
154
+
155
+ def _walk_stmts(value: Any):
156
+ """Yield every dict that looks like a statement node, recursively."""
157
+ if isinstance(value, dict):
158
+ if isinstance(value.get("kind"), str) and "handlers" in value:
159
+ yield value
160
+ for handler in value["handlers"]:
161
+ yield from _walk_stmts(handler.get("body"))
162
+ else:
163
+ for child in value.values():
164
+ yield from _walk_stmts(child)
165
+ elif isinstance(value, (list, tuple)):
166
+ for item in value:
167
+ yield from _walk_stmts(item)
pyscriptc/check.py ADDED
@@ -0,0 +1,49 @@
1
+ """Checker preflight.
2
+
3
+ mypy is a subprocess. Its AST is not an input to lowering. A bad annotation
4
+ fails the build before LLVM emission.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import subprocess
10
+ import sys
11
+ import tempfile
12
+ from pathlib import Path
13
+
14
+
15
+ class CheckFailed(Exception):
16
+ def __init__(self, output: str) -> None:
17
+ super().__init__(output)
18
+ self.output = output
19
+
20
+
21
+ def run_checker(path: Path, checker: str = "mypy") -> None:
22
+ if checker != "mypy":
23
+ raise CheckFailed(f"checker {checker!r} is not wired; the spike preflight uses mypy")
24
+ with tempfile.TemporaryDirectory(prefix="pyscriptc-mypy-") as cache:
25
+ proc = subprocess.run(
26
+ [
27
+ sys.executable,
28
+ "-m",
29
+ "mypy",
30
+ "--show-error-codes",
31
+ "--hide-error-context",
32
+ "--no-error-summary",
33
+ "--follow-imports=silent",
34
+ "--cache-dir",
35
+ cache,
36
+ "--no-incremental",
37
+ str(path),
38
+ ],
39
+ capture_output=True,
40
+ text=True,
41
+ )
42
+ if proc.returncode == 0:
43
+ return
44
+ output = (proc.stdout or proc.stderr).strip()
45
+ if not output:
46
+ output = proc.stderr.strip() or f"mypy exited {proc.returncode}"
47
+ if "No module named mypy" in output:
48
+ output = "mypy is not installed for this interpreter"
49
+ raise CheckFailed(output)
pyscriptc/cli.py ADDED
@@ -0,0 +1,271 @@
1
+ """Command line for the spike compiler."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import ast
7
+ import json
8
+ import subprocess
9
+ import sys
10
+ import time
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from pyscriptc import telemetry
15
+ from pyscriptc.check import CheckFailed, run_checker
16
+ from pyscriptc.coverage import build_report, render_text
17
+ from pyscriptc.debug import Debug, resolve
18
+ from pyscriptc.diagnostics import explain
19
+ from pyscriptc.errors import FitError, IRError, SpikeError, VersionError
20
+ from pyscriptc.ir import validate_module
21
+ from pyscriptc.llvm import emit_llvm, link_binary
22
+ from pyscriptc.log import enable_debug, get_logger, tail
23
+ from pyscriptc.lower import lower_file, render_fences
24
+ from pyscriptc.package import lower_directory
25
+
26
+ log = get_logger()
27
+
28
+
29
+ def main(argv: list[str] | None = None) -> int:
30
+ parser = argparse.ArgumentParser(
31
+ prog="pyscriptc",
32
+ description="Lower a typed Python subset to IR and LLVM.",
33
+ )
34
+ common = argparse.ArgumentParser(add_help=False)
35
+ common.add_argument("-v", "--verbose", action="store_true", help="enable DEBUG audit records")
36
+ sub = parser.add_subparsers(dest="cmd", required=True)
37
+ compile_parser = sub.add_parser("compile", help="lower a source file", parents=[common])
38
+ compile_parser.add_argument("source")
39
+ compile_parser.add_argument("--emit", choices=("ir", "llvm", "bin"), default="llvm")
40
+ compile_parser.add_argument("-o", dest="output")
41
+ compile_parser.add_argument("--call", help="function the generated main calls")
42
+ compile_parser.add_argument(
43
+ "--args",
44
+ default="[]",
45
+ help="Python literal list of arguments for --call, parsed with ast.literal_eval",
46
+ )
47
+ compile_parser.add_argument("--run", action="store_true", help="compile a binary and run it")
48
+ compile_parser.add_argument(
49
+ "--driver",
50
+ choices=("clang", "zig"),
51
+ default="clang",
52
+ help="C toolchain driver; zig means `zig cc` (required for --target)",
53
+ )
54
+ compile_parser.add_argument(
55
+ "--target",
56
+ help="cross-compilation triple for `zig cc`, e.g. aarch64-linux-gnu.2.39",
57
+ )
58
+ compile_parser.add_argument("--skip-check", action="store_true")
59
+ compile_parser.add_argument("--checker", default="mypy")
60
+ compile_parser.add_argument(
61
+ "--debug",
62
+ nargs="?",
63
+ const="",
64
+ default=None,
65
+ metavar="WHAT",
66
+ help="print a compact stage table to stderr; WHAT is a comma list of "
67
+ "stages,fences,cc,paths,all (also via PYSC_DEBUG)",
68
+ )
69
+ compile_parser.add_argument(
70
+ "--debug-dir",
71
+ metavar="DIR",
72
+ help="write debug artifacts (ir.json, out.ll, cc.sh, summary.json) to DIR",
73
+ )
74
+ coverage_parser = sub.add_parser("coverage", help="report every fence in a file or package tree", parents=[common])
75
+ coverage_parser.add_argument("source")
76
+ coverage_parser.add_argument("--json", action="store_true", help="print the report as JSON")
77
+ explain_parser = sub.add_parser("explain", help="describe a diagnostic code", parents=[common])
78
+ explain_parser.add_argument("code")
79
+ sub.add_parser("logs", help="print the audit-log path and its last lines", parents=[common])
80
+ telemetry_parser = sub.add_parser("telemetry", help="anonymous usage metrics opt-out")
81
+ telemetry_parser.add_argument("action", choices=("disable", "enable", "status"))
82
+ try:
83
+ args = parser.parse_args(argv)
84
+ if getattr(args, "verbose", False):
85
+ enable_debug()
86
+ log.info("cli.invoke", cmd=args.cmd)
87
+ telemetry.maybe_print_notice()
88
+ telemetry.track("cli.invoked", {"subcommand": args.cmd, **telemetry.base_properties()})
89
+ if args.cmd == "compile":
90
+ return compile_cmd(args)
91
+ if args.cmd == "coverage":
92
+ return coverage_cmd(args)
93
+ if args.cmd == "explain":
94
+ return explain_cmd(args)
95
+ if args.cmd == "logs":
96
+ return logs_cmd(args)
97
+ if args.cmd == "telemetry":
98
+ return telemetry.telemetry_cmd(args.action)
99
+ except CheckFailed as exc:
100
+ print(str(exc), file=sys.stderr)
101
+ return 2
102
+ except (VersionError, FitError) as exc:
103
+ print(str(exc), file=sys.stderr)
104
+ return 4
105
+ except SpikeError as exc:
106
+ print(str(exc), file=sys.stderr)
107
+ return 1
108
+ return 2
109
+
110
+
111
+ def coverage_cmd(args: argparse.Namespace) -> int:
112
+ report = build_report(Path(args.source))
113
+ log.info("coverage.report", source=args.source)
114
+ print(json.dumps(report, indent=2) if args.json else render_text(report))
115
+ return 0
116
+
117
+
118
+ def explain_cmd(args: argparse.Namespace) -> int:
119
+ text = explain(args.code)
120
+ log.info("explain.lookup", code=args.code, known=text is not None)
121
+ if text is None:
122
+ print(f"unknown diagnostic code: {args.code}", file=sys.stderr)
123
+ return 2
124
+ print(text)
125
+ return 0
126
+
127
+
128
+ def logs_cmd(args: argparse.Namespace) -> int:
129
+ tail()
130
+ return 0
131
+
132
+
133
+ def compile_cmd(args: argparse.Namespace) -> int:
134
+ started = time.monotonic()
135
+ state: dict[str, Any] = {"ok": False, "fence_code": None, "ran_bytes": None}
136
+ try:
137
+ rc = _compile_cmd(args, state)
138
+ state["ok"] = rc == 0
139
+ return rc
140
+ finally:
141
+ properties: dict[str, Any] = {
142
+ "duration_ms": int((time.monotonic() - started) * 1000),
143
+ "ok": state["ok"],
144
+ }
145
+ if state["fence_code"]:
146
+ properties["fence_code"] = state["fence_code"]
147
+ telemetry.track("compile.finished", properties)
148
+ if state["ran_bytes"] is not None:
149
+ telemetry.track("compile.ran", {"binary_bytes": state["ran_bytes"]})
150
+
151
+
152
+ def _compile_cmd(args: argparse.Namespace, state: dict[str, Any]) -> int:
153
+ try:
154
+ dbg = resolve(args.debug, args.debug_dir)
155
+ except ValueError as exc:
156
+ raise IRError(str(exc)) from exc
157
+ if dbg is None:
158
+ dbg = Debug(set(), None)
159
+ source_path = Path(args.source)
160
+ log.info(
161
+ "compile.start",
162
+ source=str(source_path),
163
+ emit=args.emit,
164
+ run=args.run,
165
+ driver=args.driver,
166
+ target=args.target,
167
+ checker=None if args.skip_check else args.checker,
168
+ )
169
+ module: dict[str, Any] | None = None
170
+ llvm_text: str | None = None
171
+ outputs: dict[str, str] = {}
172
+ status = "ok"
173
+ try:
174
+ dbg.begin("lower")
175
+ if source_path.is_dir():
176
+ module, source = lower_directory(source_path)
177
+ elif source_path.is_file():
178
+ module, source = lower_file(str(source_path))
179
+ else:
180
+ raise IRError(f"source not found: {source_path}")
181
+ dbg.end("lower")
182
+ if args.run:
183
+ args.emit = "bin"
184
+ for note in module.pop("_notes", []):
185
+ print(note, file=sys.stderr)
186
+ if not args.skip_check:
187
+ dbg.begin("check")
188
+ run_checker(source_path, args.checker)
189
+ dbg.end("check")
190
+ dbg.begin("validate")
191
+ validate_module(module)
192
+ dbg.end("validate")
193
+ ir_path = _output_path(args, source_path, ".ir.json")
194
+ ir_path.parent.mkdir(parents=True, exist_ok=True)
195
+ ir_path.write_text(json.dumps(module, indent=2) + "\n", encoding="utf-8")
196
+ outputs["ir"] = str(ir_path)
197
+ if module["fences"]:
198
+ state["fence_code"] = module["fences"][0].get("code")
199
+ log.info(
200
+ "check.fences",
201
+ count=len(module["fences"]),
202
+ codes=[fence["code"] for fence in module["fences"]],
203
+ )
204
+ print(render_fences(module, source), file=sys.stderr)
205
+ status = "fences"
206
+ return 3
207
+ log.info("emit.ir", output=str(ir_path))
208
+ if args.emit == "ir":
209
+ print(ir_path)
210
+ return 0
211
+ call_args = _parse_args(args.args) if args.call else None
212
+ dbg.begin("emit_llvm")
213
+ llvm_text = emit_llvm(module, args.call, call_args, standalone=args.emit == "bin")
214
+ dbg.end("emit_llvm")
215
+ ll_path = _output_path(args, source_path, ".ll")
216
+ ll_path.write_text(llvm_text, encoding="utf-8")
217
+ outputs["ll"] = str(ll_path)
218
+ log.info("emit.llvm", output=str(ll_path))
219
+ if args.emit == "llvm":
220
+ print(ll_path)
221
+ return 0
222
+ binary = Path(args.output) if args.output else source_path.with_suffix("")
223
+ dbg.begin("link")
224
+ link_binary(ll_path, binary, driver=args.driver, target=args.target, record=dbg.commands)
225
+ dbg.end("link")
226
+ outputs["bin"] = str(binary)
227
+ log.info("link.binary", output=str(binary), driver=args.driver, target=args.target)
228
+ try:
229
+ state["ran_bytes"] = binary.stat().st_size
230
+ except OSError:
231
+ pass
232
+ if not args.run:
233
+ print(binary)
234
+ return 0
235
+ log.info("run.start", binary=str(binary))
236
+ dbg.begin("run")
237
+ completed = subprocess.run([str(binary.absolute())])
238
+ dbg.end("run")
239
+ code = 128 + (-completed.returncode) if completed.returncode < 0 else completed.returncode
240
+ log.info("run.exit", binary=str(binary), code=code)
241
+ return code
242
+ except Exception:
243
+ status = "error"
244
+ raise
245
+ finally:
246
+ report = dbg.render(module, outputs)
247
+ if report:
248
+ print(report, file=sys.stderr)
249
+ dbg.write_dir(module, llvm_text, outputs, status)
250
+
251
+
252
+ def _parse_args(text: str) -> list[Any]:
253
+ try:
254
+ value = ast.literal_eval(text)
255
+ except (SyntaxError, ValueError) as exc:
256
+ raise IRError(f"--args is not a Python literal: {exc}") from exc
257
+ if not isinstance(value, list):
258
+ raise IRError("--args must be a list literal")
259
+ return list(value)
260
+
261
+
262
+ def _output_path(args: argparse.Namespace, source: Path, suffix: str) -> Path:
263
+ if args.output and args.emit == "ir" and suffix == ".ir.json":
264
+ return Path(args.output)
265
+ if args.output and args.emit == "llvm" and suffix == ".ll":
266
+ return Path(args.output)
267
+ if args.output and suffix == ".ll":
268
+ return Path(str(args.output) + ".ll")
269
+ if args.output and suffix == ".ir.json":
270
+ return Path(str(args.output) + ".ir.json")
271
+ return source.with_suffix(suffix)