serpentine-lang 0.4.0__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.
- serpentine_lang-0.4.0/PKG-INFO +22 -0
- serpentine_lang-0.4.0/README.md +10 -0
- serpentine_lang-0.4.0/pyproject.toml +27 -0
- serpentine_lang-0.4.0/setup.cfg +4 -0
- serpentine_lang-0.4.0/src/serpentine_lang/__init__.py +3 -0
- serpentine_lang-0.4.0/src/serpentine_lang/api.py +30 -0
- serpentine_lang-0.4.0/src/serpentine_lang/borrowck.py +171 -0
- serpentine_lang-0.4.0/src/serpentine_lang/cli.py +151 -0
- serpentine_lang-0.4.0/src/serpentine_lang/diagnostics.py +95 -0
- serpentine_lang-0.4.0/src/serpentine_lang/frontend.py +219 -0
- serpentine_lang-0.4.0/src/serpentine_lang/llvmgen.py +652 -0
- serpentine_lang-0.4.0/src/serpentine_lang/lsp.py +184 -0
- serpentine_lang-0.4.0/src/serpentine_lang/prelude.rs +646 -0
- serpentine_lang-0.4.0/src/serpentine_lang/ptypes.py +369 -0
- serpentine_lang-0.4.0/src/serpentine_lang/rustgen.py +1379 -0
- serpentine_lang-0.4.0/src/serpentine_lang/sir.py +473 -0
- serpentine_lang-0.4.0/src/serpentine_lang/typecheck.py +2444 -0
- serpentine_lang-0.4.0/src/serpentine_lang.egg-info/PKG-INFO +22 -0
- serpentine_lang-0.4.0/src/serpentine_lang.egg-info/SOURCES.txt +21 -0
- serpentine_lang-0.4.0/src/serpentine_lang.egg-info/dependency_links.txt +1 -0
- serpentine_lang-0.4.0/src/serpentine_lang.egg-info/entry_points.txt +2 -0
- serpentine_lang-0.4.0/src/serpentine_lang.egg-info/requires.txt +3 -0
- serpentine_lang-0.4.0/src/serpentine_lang.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: serpentine-lang
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Serpentine: AOT compiler for an ownership-disciplined Python subset (serp CLI)
|
|
5
|
+
Author: Serpentine contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/avijitbhuin21/Serpentine
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
Provides-Extra: llvm
|
|
11
|
+
Requires-Dist: llvmlite>=0.44; extra == "llvm"
|
|
12
|
+
|
|
13
|
+
# serpentine-lang
|
|
14
|
+
|
|
15
|
+
The Serpentine compiler (`serp` CLI). Phase-1 walking skeleton:
|
|
16
|
+
|
|
17
|
+
- `serp check entry.py` — frontend, type checker, SIR ownership analyses; exits 0/1.
|
|
18
|
+
- `serp build entry.py [-o out] [--keep-rust]` — Backend A: transpiles to Rust and invokes
|
|
19
|
+
`rustc` to produce a native binary with no libpython dependency.
|
|
20
|
+
|
|
21
|
+
The Tier-0 language is specified in the repository's `SPEC.md`; the test corpus under
|
|
22
|
+
`tests/` is the executable half of that spec.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# serpentine-lang
|
|
2
|
+
|
|
3
|
+
The Serpentine compiler (`serp` CLI). Phase-1 walking skeleton:
|
|
4
|
+
|
|
5
|
+
- `serp check entry.py` — frontend, type checker, SIR ownership analyses; exits 0/1.
|
|
6
|
+
- `serp build entry.py [-o out] [--keep-rust]` — Backend A: transpiles to Rust and invokes
|
|
7
|
+
`rustc` to produce a native binary with no libpython dependency.
|
|
8
|
+
|
|
9
|
+
The Tier-0 language is specified in the repository's `SPEC.md`; the test corpus under
|
|
10
|
+
`tests/` is the executable half of that spec.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "serpentine-lang"
|
|
7
|
+
version = "0.4.0"
|
|
8
|
+
description = "Serpentine: AOT compiler for an ownership-disciplined Python subset (serp CLI)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
authors = [{ name = "Serpentine contributors" }]
|
|
13
|
+
|
|
14
|
+
[project.urls]
|
|
15
|
+
Homepage = "https://github.com/avijitbhuin21/Serpentine"
|
|
16
|
+
|
|
17
|
+
[project.optional-dependencies]
|
|
18
|
+
llvm = ["llvmlite>=0.44"]
|
|
19
|
+
|
|
20
|
+
[project.scripts]
|
|
21
|
+
serp = "serpentine_lang.cli:main"
|
|
22
|
+
|
|
23
|
+
[tool.setuptools.packages.find]
|
|
24
|
+
where = ["src"]
|
|
25
|
+
|
|
26
|
+
[tool.setuptools.package-data]
|
|
27
|
+
serpentine_lang = ["prelude.rs"]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Checker-as-library API (RESEARCH.md §5.13): structured diagnostics for tooling.
|
|
2
|
+
|
|
3
|
+
This is the supported programmatic surface for editors, CI hooks, and the LSP
|
|
4
|
+
server. It never prints and never exits; it returns `Diagnostic` values.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .cli import run_check
|
|
10
|
+
from .diagnostics import Diagnostic, Reporter, SerpError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def check_file(path: str | Path, overlay: dict[str, str] | None = None) -> list[Diagnostic]:
|
|
14
|
+
"""Check the program rooted at `path`; return all diagnostics (empty = clean).
|
|
15
|
+
|
|
16
|
+
`overlay` maps resolved file paths to in-memory contents that shadow the
|
|
17
|
+
on-disk text, so unsaved editor buffers can be checked.
|
|
18
|
+
"""
|
|
19
|
+
rep = Reporter()
|
|
20
|
+
try:
|
|
21
|
+
run_check(Path(path), rep, overlay=overlay)
|
|
22
|
+
except SerpError:
|
|
23
|
+
pass
|
|
24
|
+
return list(rep.diags)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def check_source(path: str | Path, text: str) -> list[Diagnostic]:
|
|
28
|
+
"""Check a single in-memory buffer as the program entry point."""
|
|
29
|
+
resolved = str(Path(path).resolve())
|
|
30
|
+
return check_file(path, overlay={resolved: text})
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Ownership analyses over SIR: initialized-places dataflow + lexical loan checks.
|
|
2
|
+
|
|
3
|
+
Initialized-places is a forward may/must dataflow with a worklist (fixed point),
|
|
4
|
+
so moves inside loop bodies are caught at the loop head (SPEC.md J8). Places are
|
|
5
|
+
field-sensitive to one level ("x" and "x.f"): moving x.f leaves x.g usable but
|
|
6
|
+
the whole x unusable until x.f is re-assigned. Loans are lexical for-loop
|
|
7
|
+
regions recorded on each instruction during lowering.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import ast
|
|
11
|
+
from .diagnostics import Reporter, Span
|
|
12
|
+
from .sir import FuncSIR
|
|
13
|
+
|
|
14
|
+
UNINIT = 0
|
|
15
|
+
INIT = 1
|
|
16
|
+
MOVED = 2
|
|
17
|
+
MAYBE = 3 # conflicting states at a join: maybe-moved / maybe-uninit
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _merge(a: int, b: int) -> int:
|
|
21
|
+
if a == b:
|
|
22
|
+
return a
|
|
23
|
+
return MAYBE
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _root(var: str) -> str:
|
|
27
|
+
return var.split(".", 1)[0]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _apply(kind: str, var: str, state: dict, all_vars: list[str]) -> None:
|
|
31
|
+
"""Advance the dataflow state over one instruction."""
|
|
32
|
+
if kind == "def":
|
|
33
|
+
state[var] = INIT
|
|
34
|
+
if "." not in var:
|
|
35
|
+
for v in all_vars:
|
|
36
|
+
if v.startswith(var + "."):
|
|
37
|
+
state[v] = INIT
|
|
38
|
+
elif kind == "field_def":
|
|
39
|
+
state[var] = INIT
|
|
40
|
+
elif kind in ("move", "drop"):
|
|
41
|
+
state[var] = MOVED
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _eff(state: dict, var: str) -> int:
|
|
45
|
+
"""Effective state of a place: an untouched field inherits its root's state."""
|
|
46
|
+
s = state.get(var, UNINIT)
|
|
47
|
+
if "." in var and s == UNINIT:
|
|
48
|
+
return state.get(_root(var), UNINIT)
|
|
49
|
+
return s
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def check_function(sir: FuncSIR, rep: Reporter, file: str) -> None:
|
|
53
|
+
"""Run both analyses on one function and report diagnostics."""
|
|
54
|
+
all_vars = sorted({i.var for b in sir.blocks for i in b.instrs})
|
|
55
|
+
params = {p.name for p in sir.sig.params}
|
|
56
|
+
entry_state = {}
|
|
57
|
+
for v in all_vars:
|
|
58
|
+
if _root(v) in params:
|
|
59
|
+
entry_state[v] = INIT
|
|
60
|
+
else:
|
|
61
|
+
entry_state[v] = UNINIT
|
|
62
|
+
|
|
63
|
+
in_states: dict[int, dict[str, int] | None] = {b.id: None for b in sir.blocks}
|
|
64
|
+
in_states[sir.entry] = dict(entry_state)
|
|
65
|
+
work = [sir.entry]
|
|
66
|
+
while work:
|
|
67
|
+
bid = work.pop()
|
|
68
|
+
state = dict(in_states[bid]) # type: ignore[arg-type]
|
|
69
|
+
block = sir.blocks[bid]
|
|
70
|
+
for instr in block.instrs:
|
|
71
|
+
_apply(instr.kind, instr.var, state, all_vars)
|
|
72
|
+
for succ in block.succs:
|
|
73
|
+
merged = state if in_states[succ] is None else {
|
|
74
|
+
v: _merge(in_states[succ].get(v, UNINIT), state.get(v, UNINIT)) # type: ignore[union-attr]
|
|
75
|
+
for v in all_vars
|
|
76
|
+
}
|
|
77
|
+
if merged != in_states[succ]:
|
|
78
|
+
in_states[succ] = dict(merged)
|
|
79
|
+
work.append(succ)
|
|
80
|
+
|
|
81
|
+
move_sites: dict[str, Span] = {}
|
|
82
|
+
for block in sir.blocks:
|
|
83
|
+
for instr in block.instrs:
|
|
84
|
+
if instr.kind == "move" and instr.var not in move_sites:
|
|
85
|
+
move_sites[instr.var] = _span(instr.node, file)
|
|
86
|
+
|
|
87
|
+
for block in sir.blocks:
|
|
88
|
+
if in_states[block.id] is None:
|
|
89
|
+
continue
|
|
90
|
+
state = dict(in_states[block.id]) # type: ignore[arg-type]
|
|
91
|
+
for instr in block.instrs:
|
|
92
|
+
_check_instr(instr, state, rep, file, all_vars, move_sites)
|
|
93
|
+
_apply(instr.kind, instr.var, state, all_vars)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _span(node: ast.AST, file: str) -> Span:
|
|
97
|
+
return Span(file, getattr(node, "lineno", 1), getattr(node, "col_offset", 0),
|
|
98
|
+
getattr(node, "end_col_offset", None))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _moved_secondary(var: str, move_sites: dict) -> list:
|
|
102
|
+
site = move_sites.get(var)
|
|
103
|
+
return [(site, f"'{var}' moved here")] if site is not None else []
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _check_instr(instr, state: dict, rep: Reporter, file: str,
|
|
107
|
+
all_vars: list[str], move_sites: dict) -> None:
|
|
108
|
+
var = instr.var
|
|
109
|
+
span = _span(instr.node, file)
|
|
110
|
+
if instr.kind in ("use", "mut_use", "move"):
|
|
111
|
+
cur = _eff(state, var)
|
|
112
|
+
if cur == MOVED:
|
|
113
|
+
rep.error("SE001", "use-after-move",
|
|
114
|
+
f"use of moved value '{var}'", span,
|
|
115
|
+
label="value was moved before this use",
|
|
116
|
+
secondary=_moved_secondary(var, move_sites),
|
|
117
|
+
help=f"re-initialize '{var}' before this point, or borrow instead "
|
|
118
|
+
f"of moving")
|
|
119
|
+
elif cur == MAYBE:
|
|
120
|
+
rep.error("SE001", "use-after-move",
|
|
121
|
+
f"use of possibly-moved value '{var}' — it is moved on some "
|
|
122
|
+
f"paths reaching this point", span,
|
|
123
|
+
label="maybe-moved here",
|
|
124
|
+
secondary=_moved_secondary(var, move_sites),
|
|
125
|
+
help=f"assign '{var}' on every path (or none) before this use")
|
|
126
|
+
elif "." not in var:
|
|
127
|
+
# Whole-value use: every field must still be present (partial moves).
|
|
128
|
+
for v in all_vars:
|
|
129
|
+
if v.startswith(var + ".") and state.get(v, UNINIT) in (MOVED, MAYBE):
|
|
130
|
+
fieldname = v.split(".", 1)[1]
|
|
131
|
+
rep.error("SE001", "use-after-move",
|
|
132
|
+
f"use of partially moved value '{var}' — field "
|
|
133
|
+
f"'{fieldname}' was moved out", span,
|
|
134
|
+
label=f"'{v}' is gone here",
|
|
135
|
+
secondary=_moved_secondary(v, move_sites),
|
|
136
|
+
help=f"re-assign '{v}' before using '{var}' as a whole")
|
|
137
|
+
break
|
|
138
|
+
if "." in var:
|
|
139
|
+
root_state = state.get(_root(var), UNINIT)
|
|
140
|
+
if root_state in (MOVED, MAYBE):
|
|
141
|
+
rep.error("SE001", "use-after-move",
|
|
142
|
+
f"use of field '{var}' of moved value '{_root(var)}'", span,
|
|
143
|
+
label="the containing value was moved",
|
|
144
|
+
secondary=_moved_secondary(_root(var), move_sites))
|
|
145
|
+
if instr.kind == "field_def":
|
|
146
|
+
root_state = state.get(_root(var), UNINIT)
|
|
147
|
+
if root_state in (MOVED, MAYBE):
|
|
148
|
+
rep.error("SE001", "use-after-move",
|
|
149
|
+
f"cannot assign to field '{var}' of moved value '{_root(var)}'",
|
|
150
|
+
span, label="the containing value was moved",
|
|
151
|
+
secondary=_moved_secondary(_root(var), move_sites),
|
|
152
|
+
help=f"re-initialize '{_root(var)}' as a whole first")
|
|
153
|
+
if instr.kind in ("mut_use", "move", "def", "field_def"):
|
|
154
|
+
for (loan_var, _kind) in instr.loans:
|
|
155
|
+
if loan_var == _root(var):
|
|
156
|
+
if instr.kind == "move":
|
|
157
|
+
rep.error("SE005", "move-while-borrowed",
|
|
158
|
+
f"cannot move '{var}' while it is borrowed by the "
|
|
159
|
+
f"enclosing for loop", span,
|
|
160
|
+
label="borrowed for the whole loop body")
|
|
161
|
+
else:
|
|
162
|
+
rep.error("SE004", "conflicting-borrow",
|
|
163
|
+
f"cannot mutate '{var}' while it is borrowed by the "
|
|
164
|
+
f"enclosing for loop", span,
|
|
165
|
+
label="the loop holds a Ref borrow of it")
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def check_program(sirs: dict, rep: Reporter, files: dict[str, str]) -> None:
|
|
169
|
+
"""Run ownership analyses on every function in the program."""
|
|
170
|
+
for (mod, _fname), sir in sirs.items():
|
|
171
|
+
check_function(sir, rep, files[mod])
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""The serp CLI: check and build subcommands."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .borrowck import check_program
|
|
11
|
+
from .diagnostics import Reporter, SerpError
|
|
12
|
+
from .frontend import load_program
|
|
13
|
+
from .rustgen import CodegenError, generate_rust
|
|
14
|
+
from .sir import lower_program
|
|
15
|
+
from .typecheck import TypeChecker
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def run_check(entry: Path, rep: Reporter, dump_sir: bool = False,
|
|
19
|
+
overlay: dict[str, str] | None = None):
|
|
20
|
+
"""Run the full static pipeline; returns the TypeChecker on success."""
|
|
21
|
+
program = load_program(entry, rep, overlay)
|
|
22
|
+
tc = TypeChecker(program, rep)
|
|
23
|
+
tc.run()
|
|
24
|
+
sirs = lower_program(tc)
|
|
25
|
+
files = {m.name: str(m.path) for m in program.modules.values()}
|
|
26
|
+
check_program(sirs, rep, files)
|
|
27
|
+
if rep.diags:
|
|
28
|
+
raise SerpError(rep.diags)
|
|
29
|
+
if dump_sir:
|
|
30
|
+
for sir in sirs.values():
|
|
31
|
+
print(sir.dump())
|
|
32
|
+
return tc
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def cmd_check(args: argparse.Namespace) -> int:
|
|
36
|
+
"""serp check: report diagnostics, exit 0/1."""
|
|
37
|
+
rep = Reporter()
|
|
38
|
+
try:
|
|
39
|
+
run_check(Path(args.file), rep, dump_sir=args.dump_sir)
|
|
40
|
+
except SerpError:
|
|
41
|
+
print(rep.render_all(), file=sys.stderr)
|
|
42
|
+
n = len({(d.code, d.span.line, d.span.col) for d in rep.diags})
|
|
43
|
+
print(f"\nserp: {n} error(s)", file=sys.stderr)
|
|
44
|
+
return 1
|
|
45
|
+
print(f"serp: OK ({args.file})")
|
|
46
|
+
return 0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def cmd_build(args: argparse.Namespace) -> int:
|
|
50
|
+
"""serp build: check, transpile to Rust, invoke rustc."""
|
|
51
|
+
rep = Reporter()
|
|
52
|
+
try:
|
|
53
|
+
tc = run_check(Path(args.file), rep)
|
|
54
|
+
except SerpError:
|
|
55
|
+
print(rep.render_all(), file=sys.stderr)
|
|
56
|
+
return 1
|
|
57
|
+
try:
|
|
58
|
+
rust_src = generate_rust(tc)
|
|
59
|
+
except CodegenError as exc:
|
|
60
|
+
print(f"serp: internal codegen error: {exc}", file=sys.stderr)
|
|
61
|
+
return 2
|
|
62
|
+
|
|
63
|
+
entry = Path(args.file).resolve()
|
|
64
|
+
out = Path(args.output) if args.output else entry.with_suffix("")
|
|
65
|
+
if sys.platform == "win32" and out.suffix != ".exe":
|
|
66
|
+
out = out.with_suffix(".exe")
|
|
67
|
+
|
|
68
|
+
rustc = shutil.which("rustc")
|
|
69
|
+
if rustc is None:
|
|
70
|
+
print("serp: rustc not found on PATH — install Rust to use `serp build`", file=sys.stderr)
|
|
71
|
+
return 2
|
|
72
|
+
|
|
73
|
+
rs_dir = Path(tempfile.mkdtemp(prefix="serp_build_"))
|
|
74
|
+
rs_file = rs_dir / f"{entry.stem}.rs"
|
|
75
|
+
rs_file.write_text(rust_src, encoding="utf-8")
|
|
76
|
+
if args.keep_rust:
|
|
77
|
+
keep = entry.with_suffix(".rs")
|
|
78
|
+
keep.write_text(rust_src, encoding="utf-8")
|
|
79
|
+
print(f"serp: rust source kept at {keep}")
|
|
80
|
+
|
|
81
|
+
opt = "0" if args.debug else "2"
|
|
82
|
+
proc = subprocess.run(
|
|
83
|
+
[rustc, "--edition", "2021", "-C", f"opt-level={opt}", "-o", str(out), str(rs_file)],
|
|
84
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
85
|
+
)
|
|
86
|
+
if proc.returncode != 0:
|
|
87
|
+
print("serp: rustc rejected the generated program (checker/oracle disagreement):",
|
|
88
|
+
file=sys.stderr)
|
|
89
|
+
print(proc.stderr, file=sys.stderr)
|
|
90
|
+
return 2
|
|
91
|
+
print(f"serp: built {out}")
|
|
92
|
+
return 0
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def cmd_llvm(args: argparse.Namespace) -> int:
|
|
96
|
+
"""serp llvm: check, lower to LLVM IR (Backend B numeric core), JIT-run or dump IR."""
|
|
97
|
+
from .llvmgen import LLVMUnsupported, generate_llvm, run_llvm
|
|
98
|
+
rep = Reporter()
|
|
99
|
+
try:
|
|
100
|
+
tc = run_check(Path(args.file), rep)
|
|
101
|
+
except SerpError:
|
|
102
|
+
print(rep.render_all(), file=sys.stderr)
|
|
103
|
+
return 1
|
|
104
|
+
try:
|
|
105
|
+
if args.emit_ir:
|
|
106
|
+
print(generate_llvm(tc))
|
|
107
|
+
return 0
|
|
108
|
+
return run_llvm(tc)
|
|
109
|
+
except LLVMUnsupported as exc:
|
|
110
|
+
print(f"serp: program is outside the LLVM numeric core ({exc}) — "
|
|
111
|
+
f"use `serp build` (Rust backend)", file=sys.stderr)
|
|
112
|
+
return 3
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def cmd_lsp(args: argparse.Namespace) -> int:
|
|
116
|
+
"""serp lsp: run the Language Server Protocol server on stdio."""
|
|
117
|
+
from .lsp import serve_stdio
|
|
118
|
+
return serve_stdio()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def main() -> int:
|
|
122
|
+
"""CLI entry point."""
|
|
123
|
+
parser = argparse.ArgumentParser(prog="serp", description="Serpentine compiler")
|
|
124
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
125
|
+
|
|
126
|
+
p_check = sub.add_parser("check", help="type- and borrow-check a program")
|
|
127
|
+
p_check.add_argument("file")
|
|
128
|
+
p_check.add_argument("--dump-sir", action="store_true", help="print the SIR after checking")
|
|
129
|
+
p_check.set_defaults(func=cmd_check)
|
|
130
|
+
|
|
131
|
+
p_build = sub.add_parser("build", help="compile to a native binary via Rust")
|
|
132
|
+
p_build.add_argument("file")
|
|
133
|
+
p_build.add_argument("-o", "--output", default=None)
|
|
134
|
+
p_build.add_argument("--keep-rust", action="store_true", help="write the generated .rs next to the source")
|
|
135
|
+
p_build.add_argument("--debug", action="store_true", help="opt-level=0 for faster builds")
|
|
136
|
+
p_build.set_defaults(func=cmd_build)
|
|
137
|
+
|
|
138
|
+
p_llvm = sub.add_parser("llvm", help="run via the LLVM backend (numeric core, JIT)")
|
|
139
|
+
p_llvm.add_argument("file")
|
|
140
|
+
p_llvm.add_argument("--emit-ir", action="store_true", help="print LLVM IR instead of running")
|
|
141
|
+
p_llvm.set_defaults(func=cmd_llvm)
|
|
142
|
+
|
|
143
|
+
p_lsp = sub.add_parser("lsp", help="run the LSP server on stdio (editor diagnostics)")
|
|
144
|
+
p_lsp.set_defaults(func=cmd_lsp)
|
|
145
|
+
|
|
146
|
+
args = parser.parse_args()
|
|
147
|
+
return args.func(args)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
if __name__ == "__main__":
|
|
151
|
+
sys.exit(main())
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Diagnostics v1: stable error codes, source spans, primary labels."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class Span:
|
|
8
|
+
"""A source location: file, 1-indexed line, 0-indexed column."""
|
|
9
|
+
|
|
10
|
+
file: str
|
|
11
|
+
line: int
|
|
12
|
+
col: int
|
|
13
|
+
end_col: int | None = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Diagnostic:
|
|
18
|
+
"""A single checker error with its stable code and primary label."""
|
|
19
|
+
|
|
20
|
+
code: str
|
|
21
|
+
name: str
|
|
22
|
+
message: str
|
|
23
|
+
span: Span
|
|
24
|
+
label: str = ""
|
|
25
|
+
secondary: list = field(default_factory=list) # list[(Span, str)]
|
|
26
|
+
help: str = ""
|
|
27
|
+
|
|
28
|
+
def render(self, source_lines: dict[str, list[str]] | None = None) -> str:
|
|
29
|
+
"""Render the diagnostic in rustc-style layout with source lines and carets."""
|
|
30
|
+
out = [f"error[{self.code}]: {self.message}"]
|
|
31
|
+
out.append(f" --> {self.span.file}:{self.span.line}:{self.span.col + 1}")
|
|
32
|
+
self._render_snippet(out, self.span, self.label, "^", source_lines)
|
|
33
|
+
for (span, label) in self.secondary:
|
|
34
|
+
out.append(f" --> {span.file}:{span.line}:{span.col + 1}")
|
|
35
|
+
self._render_snippet(out, span, label, "-", source_lines)
|
|
36
|
+
if self.help:
|
|
37
|
+
out.append(f" help: {self.help}")
|
|
38
|
+
return "\n".join(out)
|
|
39
|
+
|
|
40
|
+
def _render_snippet(self, out: list[str], span: Span, label: str, marker: str,
|
|
41
|
+
source_lines: dict[str, list[str]] | None) -> None:
|
|
42
|
+
lines = (source_lines or {}).get(span.file)
|
|
43
|
+
if lines and 1 <= span.line <= len(lines):
|
|
44
|
+
src = lines[span.line - 1].rstrip("\n")
|
|
45
|
+
gutter = str(span.line)
|
|
46
|
+
pad = " " * len(gutter)
|
|
47
|
+
out.append(f"{pad} |")
|
|
48
|
+
out.append(f"{gutter} | {src}")
|
|
49
|
+
width = max(1, (span.end_col or (span.col + 1)) - span.col)
|
|
50
|
+
carets = " " * span.col + marker * width
|
|
51
|
+
suffix = f" {label}" if label else ""
|
|
52
|
+
out.append(f"{pad} | {carets}{suffix}")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class SerpError(Exception):
|
|
56
|
+
"""Raised to abort compilation with one or more diagnostics."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, diags: list[Diagnostic]):
|
|
59
|
+
super().__init__(diags[0].message if diags else "compile error")
|
|
60
|
+
self.diags = diags
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass
|
|
64
|
+
class Reporter:
|
|
65
|
+
"""Collects diagnostics and source text for rendering."""
|
|
66
|
+
|
|
67
|
+
diags: list[Diagnostic] = field(default_factory=list)
|
|
68
|
+
sources: dict[str, list[str]] = field(default_factory=dict)
|
|
69
|
+
|
|
70
|
+
def add_source(self, file: str, text: str) -> None:
|
|
71
|
+
"""Register a file's source text for diagnostic rendering."""
|
|
72
|
+
self.sources[file] = text.splitlines()
|
|
73
|
+
|
|
74
|
+
def error(self, code: str, name: str, message: str, span: Span, label: str = "",
|
|
75
|
+
secondary: list | None = None, help: str = "") -> None:
|
|
76
|
+
"""Record one diagnostic."""
|
|
77
|
+
self.diags.append(Diagnostic(code, name, message, span, label,
|
|
78
|
+
secondary or [], help))
|
|
79
|
+
|
|
80
|
+
def fatal(self, code: str, name: str, message: str, span: Span, label: str = "",
|
|
81
|
+
secondary: list | None = None, help: str = "") -> None:
|
|
82
|
+
"""Record one diagnostic and abort compilation immediately."""
|
|
83
|
+
self.error(code, name, message, span, label, secondary, help)
|
|
84
|
+
raise SerpError(self.diags)
|
|
85
|
+
|
|
86
|
+
def render_all(self) -> str:
|
|
87
|
+
"""Render every collected diagnostic, deduplicated and in source order."""
|
|
88
|
+
seen = set()
|
|
89
|
+
ordered = []
|
|
90
|
+
for d in sorted(self.diags, key=lambda d: (d.span.file, d.span.line, d.span.col, d.code)):
|
|
91
|
+
key = (d.code, d.span.file, d.span.line, d.span.col, d.message)
|
|
92
|
+
if key not in seen:
|
|
93
|
+
seen.add(key)
|
|
94
|
+
ordered.append(d)
|
|
95
|
+
return "\n\n".join(d.render(self.sources) for d in ordered)
|