gelang 0.1.0
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.
- package/CHANGELOG.md +65 -0
- package/LICENSE +21 -0
- package/README.md +535 -0
- package/bin/ge.js +112 -0
- package/package.json +62 -0
- package/python/pyeffic/__init__.py +9 -0
- package/python/pyeffic/__main__.py +6 -0
- package/python/pyeffic/analyzer.py +464 -0
- package/python/pyeffic/apisurface.py +238 -0
- package/python/pyeffic/autoselect.py +327 -0
- package/python/pyeffic/backends.py +87 -0
- package/python/pyeffic/bench.py +233 -0
- package/python/pyeffic/cli.py +184 -0
- package/python/pyeffic/compiler.py +421 -0
- package/python/pyeffic/config.py +383 -0
- package/python/pyeffic/dartgen.py +441 -0
- package/python/pyeffic/deploy.py +586 -0
- package/python/pyeffic/diagnostics.py +194 -0
- package/python/pyeffic/difftest.py +424 -0
- package/python/pyeffic/downloader.py +307 -0
- package/python/pyeffic/emitters/__init__.py +11 -0
- package/python/pyeffic/emitters/base.py +2359 -0
- package/python/pyeffic/emitters/cpp.py +266 -0
- package/python/pyeffic/emitters/csharp.py +342 -0
- package/python/pyeffic/emitters/dart.py +349 -0
- package/python/pyeffic/emitters/go.py +388 -0
- package/python/pyeffic/emitters/kotlin.py +314 -0
- package/python/pyeffic/emitters/rust.py +314 -0
- package/python/pyeffic/emitters/zig.py +411 -0
- package/python/pyeffic/ffi.py +49 -0
- package/python/pyeffic/frontends/__init__.py +94 -0
- package/python/pyeffic/frontends/hybrid.py +709 -0
- package/python/pyeffic/frontends/typescript.py +965 -0
- package/python/pyeffic/ge_cli.py +1148 -0
- package/python/pyeffic/golden.py +348 -0
- package/python/pyeffic/idents.py +206 -0
- package/python/pyeffic/modules.py +220 -0
- package/python/pyeffic/packer.py +222 -0
- package/python/pyeffic/pipeline.py +797 -0
- package/python/pyeffic/reactgen.py +966 -0
- package/python/pyeffic/researcher.py +177 -0
- package/python/pyeffic/scaffold.py +397 -0
- package/python/pyeffic/stdlib.py +246 -0
- package/python/pyeffic/styling.py +220 -0
- package/python/pyeffic/templates/desktop_gui/README.md +106 -0
- package/python/pyeffic/templates/desktop_gui/app/__init__.py +0 -0
- package/python/pyeffic/templates/desktop_gui/app/core/__init__.py +0 -0
- package/python/pyeffic/templates/desktop_gui/app/core/add.ge.py +13 -0
- package/python/pyeffic/templates/desktop_gui/app/core/factorial.ge.py +20 -0
- package/python/pyeffic/templates/desktop_gui/app/core/fibonacci.ge.py +25 -0
- package/python/pyeffic/templates/desktop_gui/app/core/gcd.ge.py +19 -0
- package/python/pyeffic/templates/desktop_gui/app/core/is_prime.ge.py +24 -0
- package/python/pyeffic/templates/desktop_gui/app/core/multiply.ge.py +13 -0
- package/python/pyeffic/templates/desktop_gui/app/core/power.ge.py +25 -0
- package/python/pyeffic/templates/desktop_gui/app/main.ge.py +49 -0
- package/python/pyeffic/templates/desktop_gui/app/memory/__init__.py +0 -0
- package/python/pyeffic/templates/desktop_gui/app/memory/buffer.ge.py +26 -0
- package/python/pyeffic/templates/desktop_gui/app/memory/limits.ge.py +47 -0
- package/python/pyeffic/templates/desktop_gui/app/memory/state.ge.py +44 -0
- package/python/pyeffic/templates/desktop_gui/app/ui/__init__.py +0 -0
- package/python/pyeffic/templates/desktop_gui/app/ui/layout.ge.py +64 -0
- package/python/pyeffic/templates/desktop_gui/app/ui/render.ge.py +87 -0
- package/python/pyeffic/templates/desktop_gui/app/ui/theme.ge.py +147 -0
- package/python/pyeffic/templates/desktop_gui/app/ui/widgets.ge.py +105 -0
- package/python/pyeffic/templates/desktop_gui/desktop/__init__.py +1 -0
- package/python/pyeffic/templates/desktop_gui/desktop/main.ge.py +258 -0
- package/python/pyeffic/templates/desktop_gui/ge.toml +16 -0
- package/python/pyeffic/templates/desktop_gui/tests/__init__.py +0 -0
- package/python/pyeffic/templates/desktop_gui/tests/ge_loader.py +76 -0
- package/python/pyeffic/templates/desktop_gui/tests/test_app.py +173 -0
- package/python/pyeffic/templates/web_react/README.md +115 -0
- package/python/pyeffic/templates/web_react/app/__init__.py +0 -0
- package/python/pyeffic/templates/web_react/app/core/__init__.py +0 -0
- package/python/pyeffic/templates/web_react/app/core/add.ge.py +9 -0
- package/python/pyeffic/templates/web_react/app/core/factorial.ge.py +16 -0
- package/python/pyeffic/templates/web_react/app/core/fibonacci.ge.py +21 -0
- package/python/pyeffic/templates/web_react/app/core/is_prime.ge.py +20 -0
- package/python/pyeffic/templates/web_react/app/core/multiply.ge.py +9 -0
- package/python/pyeffic/templates/web_react/app/main.ge.py +25 -0
- package/python/pyeffic/templates/web_react/app/memory/__init__.py +0 -0
- package/python/pyeffic/templates/web_react/app/memory/buffer.ge.py +25 -0
- package/python/pyeffic/templates/web_react/app/memory/limits.ge.py +51 -0
- package/python/pyeffic/templates/web_react/ge.toml +23 -0
- package/python/pyeffic/templates/web_react/tests/__init__.py +0 -0
- package/python/pyeffic/templates/web_react/tests/ge_loader.py +68 -0
- package/python/pyeffic/templates/web_react/tests/test_app.py +105 -0
- package/python/pyeffic/templates/web_react/ui/main.ge.ui +33 -0
- package/python/pyeffic/templates/web_react/web/__init__.py +0 -0
- package/python/pyeffic/templates/web_react/web/server.ge.py +78 -0
- package/python/pyeffic/ts2py.py +657 -0
- package/python/pyeffic/typecheck.py +232 -0
- package/python/pyeffic/ui.py +154 -0
- package/python/pyeffic/ui_dsl.py +618 -0
- package/python/pyeffic/widgets.py +87 -0
- package/scripts/README.md +42 -0
- package/scripts/check-toolchains.py +85 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Structured compiler diagnostics for GE.
|
|
2
|
+
|
|
3
|
+
Instead of raw Python tracebacks, the compiler produces user-friendly errors:
|
|
4
|
+
|
|
5
|
+
error[GE001]: unsupported feature 'try/except' at line 12
|
|
6
|
+
--> myrent.ge.py:12:5
|
|
7
|
+
|
|
|
8
|
+
12 | try:
|
|
9
|
+
| ^^^ GE does not support exception handling yet.
|
|
10
|
+
|
|
11
|
+
Error codes:
|
|
12
|
+
GE001 unsupported feature
|
|
13
|
+
GE002 type mismatch
|
|
14
|
+
GE003 untyped parameter
|
|
15
|
+
GE004 parse error
|
|
16
|
+
GE005 compilation error (rustc/clang++)
|
|
17
|
+
GE006 FFI type error
|
|
18
|
+
GE007 missing function
|
|
19
|
+
GE008 missing entry point
|
|
20
|
+
GE009 TypeScript transpilation error
|
|
21
|
+
GE010 package error
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Optional
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Diagnostic:
|
|
32
|
+
code: str
|
|
33
|
+
message: str
|
|
34
|
+
file: str = ""
|
|
35
|
+
line: int = 0
|
|
36
|
+
column: int = 0
|
|
37
|
+
source_line: str = ""
|
|
38
|
+
severity: str = "error" # error, warning, info
|
|
39
|
+
|
|
40
|
+
def format(self) -> str:
|
|
41
|
+
"""Format the diagnostic as a user-friendly error message."""
|
|
42
|
+
parts = []
|
|
43
|
+
|
|
44
|
+
# header: error[GE001]: message
|
|
45
|
+
header = f"{self.severity}[{self.code}]: {self.message}"
|
|
46
|
+
parts.append(header)
|
|
47
|
+
|
|
48
|
+
# location: --> file:line:column
|
|
49
|
+
if self.file and self.line > 0:
|
|
50
|
+
loc = f" --> {self.file}:{self.line}"
|
|
51
|
+
if self.column > 0:
|
|
52
|
+
loc += f":{self.column}"
|
|
53
|
+
parts.append(loc)
|
|
54
|
+
|
|
55
|
+
# source context with caret
|
|
56
|
+
if self.source_line:
|
|
57
|
+
line_num = str(self.line)
|
|
58
|
+
pad = " " * len(line_num)
|
|
59
|
+
parts.append(f" {pad} |")
|
|
60
|
+
parts.append(f" {line_num} | {self.source_line}")
|
|
61
|
+
if self.column > 0:
|
|
62
|
+
caret_pad = " " * (self.column - 1)
|
|
63
|
+
caret_len = max(1, len(self.message.split()[0]) if self.message else 1)
|
|
64
|
+
parts.append(f" {pad} | {caret_pad}{'^' * caret_len}")
|
|
65
|
+
parts.append(f" {pad} |")
|
|
66
|
+
|
|
67
|
+
return "\n".join(parts)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class ErrorReporter:
|
|
71
|
+
"""Collects and reports compiler diagnostics."""
|
|
72
|
+
|
|
73
|
+
def __init__(self):
|
|
74
|
+
self.diagnostics: list[Diagnostic] = []
|
|
75
|
+
|
|
76
|
+
def error(self, code: str, message: str, file: str = "", line: int = 0,
|
|
77
|
+
column: int = 0, source_line: str = "") -> None:
|
|
78
|
+
self.diagnostics.append(Diagnostic(code, message, file, line, column,
|
|
79
|
+
source_line, "error"))
|
|
80
|
+
|
|
81
|
+
def warning(self, code: str, message: str, file: str = "", line: int = 0,
|
|
82
|
+
column: int = 0, source_line: str = "") -> None:
|
|
83
|
+
self.diagnostics.append(Diagnostic(code, message, file, line, column,
|
|
84
|
+
source_line, "warning"))
|
|
85
|
+
|
|
86
|
+
def has_errors(self) -> bool:
|
|
87
|
+
return any(d.severity == "error" for d in self.diagnostics)
|
|
88
|
+
|
|
89
|
+
def format_all(self) -> str:
|
|
90
|
+
if not self.diagnostics:
|
|
91
|
+
return ""
|
|
92
|
+
parts = [d.format() for d in self.diagnostics]
|
|
93
|
+
error_count = sum(1 for d in self.diagnostics if d.severity == "error")
|
|
94
|
+
warning_count = sum(1 for d in self.diagnostics if d.severity == "warning")
|
|
95
|
+
summary = f"\n{error_count} error(s), {warning_count} warning(s)"
|
|
96
|
+
return "\n\n".join(parts) + summary
|
|
97
|
+
|
|
98
|
+
def print(self) -> None:
|
|
99
|
+
formatted = self.format_all()
|
|
100
|
+
if formatted:
|
|
101
|
+
print(formatted)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def report_unsupported(file: str, line: int, feature: str, source_line: str = "") -> Diagnostic:
|
|
105
|
+
"""Create a GE001 unsupported feature diagnostic."""
|
|
106
|
+
return Diagnostic(
|
|
107
|
+
code="GE001",
|
|
108
|
+
message=f"unsupported feature '{feature}' — GE does not support this yet",
|
|
109
|
+
file=file,
|
|
110
|
+
line=line,
|
|
111
|
+
source_line=source_line,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def report_type_mismatch(file: str, line: int, expected: str, got: str,
|
|
116
|
+
source_line: str = "") -> Diagnostic:
|
|
117
|
+
"""Create a GE002 type mismatch diagnostic."""
|
|
118
|
+
return Diagnostic(
|
|
119
|
+
code="GE002",
|
|
120
|
+
message=f"type mismatch: expected '{expected}', got '{got}'",
|
|
121
|
+
file=file,
|
|
122
|
+
line=line,
|
|
123
|
+
source_line=source_line,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def report_untyped_param(file: str, line: int, param_name: str,
|
|
128
|
+
source_line: str = "") -> Diagnostic:
|
|
129
|
+
"""Create a GE003 untyped parameter diagnostic."""
|
|
130
|
+
return Diagnostic(
|
|
131
|
+
code="GE003",
|
|
132
|
+
message=f"parameter '{param_name}' has no type annotation — GE requires typed parameters",
|
|
133
|
+
file=file,
|
|
134
|
+
line=line,
|
|
135
|
+
source_line=source_line,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def report_parse_error(file: str, line: int, message: str,
|
|
140
|
+
source_line: str = "") -> Diagnostic:
|
|
141
|
+
"""Create a GE004 parse error diagnostic."""
|
|
142
|
+
return Diagnostic(
|
|
143
|
+
code="GE004",
|
|
144
|
+
message=message,
|
|
145
|
+
file=file,
|
|
146
|
+
line=line,
|
|
147
|
+
source_line=source_line,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def report_compile_error(backend: str, log: str) -> Diagnostic:
|
|
152
|
+
"""Create a GE005 compilation error diagnostic from compiler output."""
|
|
153
|
+
# try to extract the first error line from the log
|
|
154
|
+
first_error = ""
|
|
155
|
+
error_line = 0
|
|
156
|
+
for line in log.splitlines():
|
|
157
|
+
if "error" in line.lower():
|
|
158
|
+
first_error = line.strip()
|
|
159
|
+
# try to extract line number from compiler output
|
|
160
|
+
# patterns: file:line:col, file(line,col), file:line, etc.
|
|
161
|
+
import re
|
|
162
|
+
m = re.search(r':(\d+)(?::\d+)?(?:\s*:|\s*\))', line)
|
|
163
|
+
if m:
|
|
164
|
+
try:
|
|
165
|
+
error_line = int(m.group(1))
|
|
166
|
+
except ValueError:
|
|
167
|
+
pass
|
|
168
|
+
break
|
|
169
|
+
msg = f"{backend} compilation failed"
|
|
170
|
+
if first_error:
|
|
171
|
+
msg += f": {first_error[:200]}"
|
|
172
|
+
return Diagnostic(
|
|
173
|
+
code="GE005",
|
|
174
|
+
message=msg,
|
|
175
|
+
line=error_line,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def report_missing_function(file: str, name: str) -> Diagnostic:
|
|
180
|
+
"""Create a GE007 missing function diagnostic."""
|
|
181
|
+
return Diagnostic(
|
|
182
|
+
code="GE007",
|
|
183
|
+
message=f"function '{name}' is called but not defined or not exported",
|
|
184
|
+
file=file,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def report_missing_entry(file: str, entry: str) -> Diagnostic:
|
|
189
|
+
"""Create a GE008 missing entry point diagnostic."""
|
|
190
|
+
return Diagnostic(
|
|
191
|
+
code="GE008",
|
|
192
|
+
message=f"entry point '{entry}' not found in source",
|
|
193
|
+
file=file,
|
|
194
|
+
)
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
"""Differential testing: prove every backend agrees with Python.
|
|
2
|
+
|
|
3
|
+
GE emits many backends from one IR, so the same program can be run through
|
|
4
|
+
CPython and through every native binary and the outputs compared. That makes
|
|
5
|
+
cross-backend agreement a *checkable property* rather than a hope.
|
|
6
|
+
|
|
7
|
+
python -m pyeffic.difftest <file-or-dir> [--backends rust,cpp,...]
|
|
8
|
+
|
|
9
|
+
Lanes
|
|
10
|
+
-----
|
|
11
|
+
reference CPython executes the lowered program — the oracle
|
|
12
|
+
parity each backend's binary must match the oracle byte-for-byte
|
|
13
|
+
(stdout, and optionally exit code)
|
|
14
|
+
|
|
15
|
+
This is the technique `scriptc` uses with Node.js as its oracle, and the one
|
|
16
|
+
Rustlantis used to find 22 previously-unknown Rust compiler bugs.
|
|
17
|
+
|
|
18
|
+
Corpus conventions
|
|
19
|
+
------------------
|
|
20
|
+
A case is a `.ge`, `.ge.py`, or `.ge.ts` file with a `main()` that prints.
|
|
21
|
+
Optional directives in the first lines:
|
|
22
|
+
|
|
23
|
+
// @expect: 42 expected stdout (skips the CPython run)
|
|
24
|
+
// @skip: cpp exclude a backend
|
|
25
|
+
// @only: rust run only these backends
|
|
26
|
+
// @exit: 1 expected exit code
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import io
|
|
31
|
+
import os
|
|
32
|
+
import subprocess
|
|
33
|
+
import sys
|
|
34
|
+
import tempfile
|
|
35
|
+
import traceback
|
|
36
|
+
from contextlib import redirect_stdout
|
|
37
|
+
from dataclasses import dataclass, field
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
|
|
40
|
+
from .config import Config, detect_compilers
|
|
41
|
+
|
|
42
|
+
#: backends we can build and run, in a stable order
|
|
43
|
+
RUNNABLE_BACKENDS = ("rust", "cpp", "csharp", "zig", "go", "kotlin")
|
|
44
|
+
|
|
45
|
+
_EXE = ".exe" if sys.platform == "win32" else ""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# Results
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class RunResult:
|
|
54
|
+
backend: str
|
|
55
|
+
ok: bool
|
|
56
|
+
stdout: str = ""
|
|
57
|
+
stderr: str = ""
|
|
58
|
+
exit_code: int = 0
|
|
59
|
+
note: str = ""
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def normalised(self) -> str:
|
|
63
|
+
"""Stdout with trailing whitespace normalised for comparison."""
|
|
64
|
+
return "\n".join(line.rstrip() for line in self.stdout.splitlines()).strip()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass
|
|
68
|
+
class DiffReport:
|
|
69
|
+
source: Path
|
|
70
|
+
reference: RunResult | None = None
|
|
71
|
+
results: dict[str, RunResult] = field(default_factory=dict)
|
|
72
|
+
skipped: dict[str, str] = field(default_factory=dict)
|
|
73
|
+
error: str = ""
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def expected(self) -> str:
|
|
77
|
+
if self.reference and self.reference.ok:
|
|
78
|
+
return self.reference.normalised
|
|
79
|
+
return ""
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def mismatches(self) -> dict[str, str]:
|
|
83
|
+
"""backend -> why it disagrees with the oracle."""
|
|
84
|
+
want = self.expected
|
|
85
|
+
out: dict[str, str] = {}
|
|
86
|
+
for backend, r in self.results.items():
|
|
87
|
+
if not r.ok:
|
|
88
|
+
out[backend] = r.note or "build or run failed"
|
|
89
|
+
elif r.normalised != want:
|
|
90
|
+
out[backend] = (f"stdout differs\n"
|
|
91
|
+
f" expected: {want!r}\n"
|
|
92
|
+
f" actual : {r.normalised!r}")
|
|
93
|
+
return out
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def passed(self) -> bool:
|
|
97
|
+
return not self.error and not self.mismatches
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def compared(self) -> int:
|
|
101
|
+
return len(self.results) - len(self.mismatches)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ---------------------------------------------------------------------------
|
|
105
|
+
# Directives
|
|
106
|
+
# ---------------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
def _directives(source: str) -> dict[str, str]:
|
|
109
|
+
"""Parse leading `// @key: value` / `# @key: value` directives.
|
|
110
|
+
|
|
111
|
+
A repeated key accumulates, one value per line, so a program with
|
|
112
|
+
several output lines can list several `@expect` directives.
|
|
113
|
+
"""
|
|
114
|
+
out: dict[str, str] = {}
|
|
115
|
+
for line in source.splitlines()[:24]:
|
|
116
|
+
s = line.strip()
|
|
117
|
+
for prefix in ("//", "#"):
|
|
118
|
+
if s.startswith(prefix):
|
|
119
|
+
body = s[len(prefix):].strip()
|
|
120
|
+
if body.startswith("@") and ":" in body:
|
|
121
|
+
key, _, val = body[1:].partition(":")
|
|
122
|
+
key = key.strip().lower()
|
|
123
|
+
if key in out:
|
|
124
|
+
out[key] = out[key] + "\n" + val.strip()
|
|
125
|
+
else:
|
|
126
|
+
out[key] = val.strip()
|
|
127
|
+
break
|
|
128
|
+
return out
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
# Reference lane: CPython
|
|
133
|
+
# ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
def _stub_preamble(backend, code): # noqa: ARG001
|
|
136
|
+
return None
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _stub_value(*args, **kwargs): # noqa: ARG001
|
|
140
|
+
return 0
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _stub_decorator(*args, **kwargs):
|
|
144
|
+
"""`@rust` / `@cpp` used bare, or called with a function."""
|
|
145
|
+
if len(args) == 1 and callable(args[0]):
|
|
146
|
+
return args[0]
|
|
147
|
+
|
|
148
|
+
def _wrap(fn):
|
|
149
|
+
return fn
|
|
150
|
+
return _wrap
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def run_reference(source_path: Path) -> RunResult:
|
|
154
|
+
"""Execute the lowered program under CPython and capture stdout."""
|
|
155
|
+
from .frontends import frontend_for
|
|
156
|
+
from .modules import _lower_source
|
|
157
|
+
|
|
158
|
+
result = RunResult(backend="python", ok=False)
|
|
159
|
+
try:
|
|
160
|
+
raw = source_path.read_text(encoding="utf-8")
|
|
161
|
+
if frontend_for(source_path) != "python":
|
|
162
|
+
code = _lower_source(raw, source_path)
|
|
163
|
+
else:
|
|
164
|
+
code = raw
|
|
165
|
+
except Exception as exc:
|
|
166
|
+
result.note = f"lowering failed: {exc}"
|
|
167
|
+
return result
|
|
168
|
+
|
|
169
|
+
# Strip compiler-only imports and give the intrinsics no-op stubs so the
|
|
170
|
+
# program can run as ordinary Python.
|
|
171
|
+
lines = []
|
|
172
|
+
for line in code.splitlines():
|
|
173
|
+
s = line.strip()
|
|
174
|
+
if s.startswith("from pyeffic.") or s.startswith("import pyeffic"):
|
|
175
|
+
continue
|
|
176
|
+
if s.startswith("from __future__"):
|
|
177
|
+
continue
|
|
178
|
+
lines.append(line)
|
|
179
|
+
code = "\n".join(lines)
|
|
180
|
+
|
|
181
|
+
ns: dict = {
|
|
182
|
+
"__name__": "__main__",
|
|
183
|
+
"ge_preamble": _stub_preamble,
|
|
184
|
+
"ge_inline": _stub_value,
|
|
185
|
+
"ge_raw": _stub_value,
|
|
186
|
+
"gePreamble": _stub_preamble,
|
|
187
|
+
"geInline": _stub_value,
|
|
188
|
+
"geRaw": _stub_value,
|
|
189
|
+
"print": print,
|
|
190
|
+
}
|
|
191
|
+
for backend in RUNNABLE_BACKENDS:
|
|
192
|
+
ns[backend] = _stub_decorator
|
|
193
|
+
|
|
194
|
+
buf = io.StringIO()
|
|
195
|
+
try:
|
|
196
|
+
exec(compile(code, str(source_path), "exec"), ns) # noqa: S102
|
|
197
|
+
with redirect_stdout(buf):
|
|
198
|
+
main = ns.get("main")
|
|
199
|
+
if callable(main):
|
|
200
|
+
main()
|
|
201
|
+
else:
|
|
202
|
+
# module-level program: re-exec with stdout captured
|
|
203
|
+
exec(compile(code, str(source_path), "exec"), ns) # noqa: S102
|
|
204
|
+
result.stdout = buf.getvalue()
|
|
205
|
+
result.ok = True
|
|
206
|
+
except SystemExit as exc:
|
|
207
|
+
result.stdout = buf.getvalue()
|
|
208
|
+
result.exit_code = int(exc.code or 0)
|
|
209
|
+
result.ok = True
|
|
210
|
+
except Exception:
|
|
211
|
+
result.stdout = buf.getvalue()
|
|
212
|
+
result.note = "runtime error: " + traceback.format_exc(limit=1).strip().splitlines()[-1]
|
|
213
|
+
return result
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# ---------------------------------------------------------------------------
|
|
217
|
+
# Parity lane: native backends
|
|
218
|
+
# ---------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
def available_backends(requested: tuple[str, ...] | None = None) -> list[str]:
|
|
221
|
+
"""Backends whose toolchain is actually installed."""
|
|
222
|
+
info = detect_compilers()
|
|
223
|
+
have = {
|
|
224
|
+
"rust": bool(info.rustc),
|
|
225
|
+
"cpp": bool(info.cpp),
|
|
226
|
+
"csharp": bool(info.dotnet),
|
|
227
|
+
"zig": bool(info.zig),
|
|
228
|
+
"go": bool(info.go),
|
|
229
|
+
"kotlin": bool(info.kotlinc),
|
|
230
|
+
}
|
|
231
|
+
wanted = requested or RUNNABLE_BACKENDS
|
|
232
|
+
return [b for b in wanted if have.get(b)]
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _exe_for(report, backend: str) -> Path | None:
|
|
236
|
+
cr = {
|
|
237
|
+
"rust": report.rust_compile,
|
|
238
|
+
"cpp": report.cpp_compile,
|
|
239
|
+
"csharp": report.csharp_compile,
|
|
240
|
+
"zig": report.zig_compile,
|
|
241
|
+
"go": report.go_compile,
|
|
242
|
+
"kotlin": report.kotlin_compile,
|
|
243
|
+
}.get(backend)
|
|
244
|
+
if cr is None:
|
|
245
|
+
return None
|
|
246
|
+
return cr.exe if cr.ok else None
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def run_backend(source_path: Path, backend: str, timeout: int = 60,
|
|
250
|
+
workdir: Path | None = None) -> RunResult:
|
|
251
|
+
"""Build the program for one backend, run it, capture stdout."""
|
|
252
|
+
from .pipeline import build
|
|
253
|
+
|
|
254
|
+
result = RunResult(backend=backend, ok=False)
|
|
255
|
+
with tempfile.TemporaryDirectory() as td:
|
|
256
|
+
out = Path(workdir) if workdir else Path(td) / "build"
|
|
257
|
+
cfg = Config(out_dir=out, target="desktop", do_research=False,
|
|
258
|
+
force_backend=backend)
|
|
259
|
+
try:
|
|
260
|
+
report = build(source_path, cfg, entry="main")
|
|
261
|
+
except Exception as exc:
|
|
262
|
+
result.note = f"compiler error: {exc}"
|
|
263
|
+
return result
|
|
264
|
+
|
|
265
|
+
if report.errors.has_errors():
|
|
266
|
+
first = report.errors.diagnostics[0]
|
|
267
|
+
result.note = f"build failed: {getattr(first, 'message', first)}"
|
|
268
|
+
return result
|
|
269
|
+
|
|
270
|
+
exe = _exe_for(report, backend)
|
|
271
|
+
if exe is None or not Path(exe).exists():
|
|
272
|
+
result.note = "no executable produced"
|
|
273
|
+
return result
|
|
274
|
+
|
|
275
|
+
try:
|
|
276
|
+
proc = subprocess.run([str(exe)], capture_output=True, text=True,
|
|
277
|
+
timeout=timeout)
|
|
278
|
+
except subprocess.TimeoutExpired:
|
|
279
|
+
result.note = f"timed out after {timeout}s"
|
|
280
|
+
return result
|
|
281
|
+
result.stdout = proc.stdout
|
|
282
|
+
result.stderr = proc.stderr
|
|
283
|
+
result.exit_code = proc.returncode
|
|
284
|
+
result.ok = True
|
|
285
|
+
return result
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
# ---------------------------------------------------------------------------
|
|
289
|
+
# Driver
|
|
290
|
+
# ---------------------------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
def diff_one(source_path: Path, backends: list[str] | None = None,
|
|
293
|
+
timeout: int = 60) -> DiffReport:
|
|
294
|
+
"""Compare one program's output across CPython and every backend."""
|
|
295
|
+
report = DiffReport(source=source_path)
|
|
296
|
+
raw = source_path.read_text(encoding="utf-8")
|
|
297
|
+
directives = _directives(raw)
|
|
298
|
+
|
|
299
|
+
expected = directives.get("expect")
|
|
300
|
+
skip = {b.strip() for b in directives.get("skip", "").split(",") if b.strip()}
|
|
301
|
+
only = {b.strip() for b in directives.get("only", "").split(",") if b.strip()}
|
|
302
|
+
|
|
303
|
+
candidates = backends if backends is not None else available_backends()
|
|
304
|
+
if only:
|
|
305
|
+
candidates = [b for b in candidates if b in only]
|
|
306
|
+
candidates = [b for b in candidates if b not in skip]
|
|
307
|
+
|
|
308
|
+
if expected is not None:
|
|
309
|
+
report.reference = RunResult(backend="python", ok=True, stdout=expected)
|
|
310
|
+
else:
|
|
311
|
+
report.reference = run_reference(source_path)
|
|
312
|
+
if not report.reference.ok:
|
|
313
|
+
report.error = f"reference failed: {report.reference.note}"
|
|
314
|
+
return report
|
|
315
|
+
|
|
316
|
+
for backend in candidates:
|
|
317
|
+
report.results[backend] = run_backend(source_path, backend, timeout=timeout)
|
|
318
|
+
|
|
319
|
+
for backend in RUNNABLE_BACKENDS:
|
|
320
|
+
if backend not in report.results:
|
|
321
|
+
report.skipped[backend] = "not requested or toolchain missing"
|
|
322
|
+
return report
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def find_cases(root: Path) -> list[Path]:
|
|
326
|
+
"""Every runnable case under `root`."""
|
|
327
|
+
from .frontends import is_ge_source
|
|
328
|
+
|
|
329
|
+
if root.is_file():
|
|
330
|
+
return [root]
|
|
331
|
+
out: list[Path] = []
|
|
332
|
+
for p in sorted(root.rglob("*")):
|
|
333
|
+
if p.is_file() and is_ge_source(p):
|
|
334
|
+
out.append(p)
|
|
335
|
+
return out
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def diff_corpus(root: Path, backends: list[str] | None = None,
|
|
339
|
+
timeout: int = 60, verbose: bool = False) -> list[DiffReport]:
|
|
340
|
+
"""Run the differential lane over a file or directory."""
|
|
341
|
+
reports: list[DiffReport] = []
|
|
342
|
+
for case in find_cases(root):
|
|
343
|
+
rep = diff_one(case, backends=backends, timeout=timeout)
|
|
344
|
+
reports.append(rep)
|
|
345
|
+
if verbose:
|
|
346
|
+
mark = "ok " if rep.passed else "DIFF"
|
|
347
|
+
print(f" [{mark}] {case.name} "
|
|
348
|
+
f"({rep.compared}/{len(rep.results)} backends agree)")
|
|
349
|
+
for backend, why in rep.mismatches.items():
|
|
350
|
+
print(f" {backend}: {why}")
|
|
351
|
+
if rep.error:
|
|
352
|
+
print(f" {rep.error}")
|
|
353
|
+
return reports
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
# ---------------------------------------------------------------------------
|
|
357
|
+
# CLI
|
|
358
|
+
# ---------------------------------------------------------------------------
|
|
359
|
+
|
|
360
|
+
def main(argv: list[str] | None = None) -> int:
|
|
361
|
+
import argparse
|
|
362
|
+
|
|
363
|
+
p = argparse.ArgumentParser(
|
|
364
|
+
prog="python -m pyeffic.difftest",
|
|
365
|
+
description="Compare CPython and every native backend on the same program")
|
|
366
|
+
p.add_argument("path", help="a .ge file or a directory of them")
|
|
367
|
+
p.add_argument("--backends", default=None,
|
|
368
|
+
help="comma-separated subset (default: all installed)")
|
|
369
|
+
p.add_argument("--timeout", type=int, default=60,
|
|
370
|
+
help="per-program run timeout in seconds")
|
|
371
|
+
p.add_argument("-v", "--verbose", action="store_true")
|
|
372
|
+
args = p.parse_args(argv)
|
|
373
|
+
|
|
374
|
+
requested = None
|
|
375
|
+
if args.backends:
|
|
376
|
+
requested = tuple(b.strip() for b in args.backends.split(",") if b.strip())
|
|
377
|
+
|
|
378
|
+
root = Path(args.path)
|
|
379
|
+
if not root.exists():
|
|
380
|
+
print(f"error: {root} not found", file=sys.stderr)
|
|
381
|
+
return 2
|
|
382
|
+
|
|
383
|
+
backends = available_backends(requested)
|
|
384
|
+
print("GE differential test")
|
|
385
|
+
print(f" corpus : {root}")
|
|
386
|
+
print(f" oracle : CPython")
|
|
387
|
+
print(f" backends: {', '.join(backends) if backends else '(none installed)'}")
|
|
388
|
+
print()
|
|
389
|
+
|
|
390
|
+
if not backends:
|
|
391
|
+
print("no backend toolchains available — nothing to compare")
|
|
392
|
+
return 2
|
|
393
|
+
|
|
394
|
+
reports = diff_corpus(root, backends=backends, timeout=args.timeout,
|
|
395
|
+
verbose=args.verbose)
|
|
396
|
+
total = len(reports)
|
|
397
|
+
failed = [r for r in reports if not r.passed]
|
|
398
|
+
comparisons = sum(len(r.results) for r in reports)
|
|
399
|
+
agreements = sum(r.compared for r in reports)
|
|
400
|
+
|
|
401
|
+
print()
|
|
402
|
+
print(f" programs : {total}")
|
|
403
|
+
print(f" comparisons: {comparisons}")
|
|
404
|
+
print(f" agreements : {agreements}")
|
|
405
|
+
print(f" mismatches : {comparisons - agreements}")
|
|
406
|
+
|
|
407
|
+
if failed:
|
|
408
|
+
print()
|
|
409
|
+
print("FAILED:")
|
|
410
|
+
for r in failed:
|
|
411
|
+
print(f" {r.source}")
|
|
412
|
+
if r.error:
|
|
413
|
+
print(f" {r.error}")
|
|
414
|
+
for backend, why in r.mismatches.items():
|
|
415
|
+
print(f" {backend}: {why}")
|
|
416
|
+
return 1
|
|
417
|
+
|
|
418
|
+
print()
|
|
419
|
+
print("all backends agree with CPython")
|
|
420
|
+
return 0
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
if __name__ == "__main__":
|
|
424
|
+
sys.exit(main())
|