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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """Serpentine compiler package."""
2
+
3
+ __version__ = "0.1.0"
@@ -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)