Commit Graph
1022 Commits
Author SHA1 Message Date
TermiNexus 98d4da7602 feat(option): allow case None on nullable pointers
None stays a keyword literal; the pattern parser already produced MatchSingleton, and the nullable-pointer match now treats it as the Nil branch. LSP colors None as a keyword.
2026-11-03 15:19:53 +08:00
TermiNexus c7ab075594 feat(option): match nullable pointers with Some(v)/Nil; align LSP captures
Compiler:
- `match p:` where p is a nullable pointer (`own U?` / `T?`) now treats
  `Some(v)` as the non-null branch (v is the pointer, so `v.field` works)
  and `Nil` / `Nil()` as the null branch. The subject's pointee must be a
  plain struct (a renum pointee keeps the tag-based renum path).
- Guards are rejected for this form (not implemented), and unsupported
  patterns fail fast.

LSP:
- `case Some(v)` captures are declared and bound to the match subject's
  class in a second pass, so `v.a` resolves and is colored as a property.
- `case Nil:` is no longer treated as a capture variable; `Some` / `Nil`
  get class/property semantic colors.

Probe: the reported ViperTestProject snippet compiles and prints
1 / 8 / 5 (v.a = 5); semantic tokens show Some=class, v=variable,
v.a property, Nil=property. Self-host 110/110 and the regression set
(EnumTest, ListTest, GenericTest/3/4, NewTest, NewLifetimeTest,
StructTest) pass.
2026-11-02 22:40:23 +08:00
TermiNexus c81ebaa7d4 feat(option): builtin Option uses the Nil variant
The lazily registered builtin template now declares Nil = 0 instead of None_.
2026-11-02 22:15:15 +08:00
TermiNexus 695c886ed0 fix(lsp): walk MatchClass sub-patterns as patterns so case captures bind
case Some(v): v is now declared and colored as a variable in the arm body.
2026-11-02 22:07:52 +08:00
TermiNexus 1f491c6dba feat(renum): generic Option[T] and Option pattern matching
- Generic renum specialization: `Option[int].Some(42)` / `Option[int].None_()`
  are intercepted before method dispatch (which would translate the
  subscript type argument as a value), specialize the renum template and
  construct the variant. `_ctor_call_agg_ty` recognizes the generic
  qualifier so construction materializes the renum value.
- Builtin Option: the generator registers `renum Option[T]` (None_ |
  Some(T)) lazily on first `Option[...]` use, with a reserved synthetic
  module id. The copy in includes/builtins.vp is skipped during Phase1
  template/variant registration so a user-defined `renum Option` (EnumTest)
  keeps precedence; constructor/annotation lookup prefers the current
  module's Option.
- Match: the renum class is derived from the concrete subject struct
  (authoritative for `Option[int]`) instead of only the annotation name.
  Both `case Option[int].Some(v)` and `case Some(v)` bind the payload;
  `None_()` works qualified or bare.

LSP: pattern captures (`case Some(v)`) are declared as variables.

Probe: builtin Option without importing builtins prints 42 for Some and
hits the None_ arm; EnumTest (user renum Option) passes; self-host
110/110; full regression set passes.
2026-11-02 22:05:09 +08:00
TermiNexus 51c0c7dae9 fix(option): base?.field yields the field value; resolve chained members in LSP
- A value field under `?.` now yields its value (zero when the base is
  null) instead of a pointer to the field. `print(_U3.n?.d)` prints the
  stored `d` (8), not an address. Pointer fields still yield the pointer
  (null on a null base), so `a?.next?.x` keeps working.
- LSP `_LocalMember` follows an intermediate field's declared type when a
  chain continues (`_U3.n.d` / `_U3.n?.d`), so `d` resolves and is colored
  as a property instead of being dropped as unknown.

Probe: the reported snippet prints 1 and 8; semantic tokens for
`print(_U3.n?.d)` include `_U3` (variable), `n` and `d` (property);
completion after `_U3.n?.` offers the U members. Self-host 110/110;
ListTest, GenericTest, GenericTest3/4, PtrOpProbe, HashLibTest, NewTest,
NewLifetimeTest, StructTest and EscapeOkTest pass.
2026-11-02 20:34:03 +08:00
TermiNexus faf1f17b22 feat(option): base?.field optional access and nullable own
Option access:
- `base?.field` evaluates to `Option[field]`: a null base yields null.
  A value field yields its address (`Option[int]` = `int*`); a pointer
  field is loaded only on the non-null branch and selected with null.
  Chaining (`n?.next?.a`) works through the pointer result.
- The AST `Attribute.optional` flag drives this in translate_attribute.

`own` nullable fix:
- `n: own U? = None` was rejected because the own-None check ignored the
  `?` wrapper. The check now skips nullable annotations, so nullable owned
  bindings accept None; non-null `own T = None` still fails.

LSP:
- member completion treats `n?.` as `n.` (base `n`, not `n?`), so fields
  and methods are offered after `?.`; semantic tokens and diagnostics
  already resolve through the optional attribute.

Probe: null `n?.a` prints the None branch; after `n = new U(5)`,
`q: Option[int] = n?.a; *q` = 5; `n?.next` null-checks; `U.__sizeof__()`
= 16; LSP completion after `n?.` offers `a`/`next` and diagnostics are
clean. Self-host 110/110; ListTest, GenericTest, GenericTest3/4,
PtrOpProbe, HashLibTest, NewTest, NewLifetimeTest, StructTest and
EscapeOkTest pass.
2026-11-02 20:24:34 +08:00
TermiNexus 3b8dc1377d feat(lsp): leave moved variables and their members uncolored
Semantic tokens now drop a variable token whose name was moved before that
line, and a property token whose base (`x` in `x.a`) is such a name. The
transfer statement itself keeps its colors (moves on the same line are
ignored for this filter), so only later uses turn plain/white.

With
  _U2: own U = _U
  _U3: own U = _U2
  print(_U2.a)
line 3 emits only `print`; `_U2` and `a` have no semantic token. The
move diagnostic still marks `_U2` separately.

Completion filtering is unchanged (`_` offers only `_U3`).
2026-11-02 20:09:54 +08:00
TermiNexus 6e6b94fdfb feat(lsp): hide moved names from completion, flag pointer-to-value writes
- Completion now computes the names moved before the cursor in the
  enclosing function and omits them, so after
  `_U2: own U = _U` typing `_` no longer offers `_U`.
- Diagnostics report a pointer/`own` value stored into a value-typed
  annotation (`_U3: U = _U2`) as an error, matching the compiler's
  CastError; pointer/own/optional targets stay clean.

Verified over textDocument/completion and textDocument/diagnostic:
`_` offers only `_U2`; `_U3: U = _U2` yields one severity-1 diagnostic on
`_U2`; `_U3: own U = _U2` yields none.
2026-11-02 20:02:46 +08:00
TermiNexus f31c1dbc78 feat(own): enforce move use-after-move and reject pointer-to-value transfer
Compiler:
- moving an owned value (`b: own T = a`) marks the source released; this
  now also applies outside OBS files when the target is declared `own`.
  Later reads/field access fail with a LifetimeError.
- transferring an owned pointer into a value-typed target
  (`_U3: U = _U2`) now fails with CastError before codegen. Previously it
  emitted a bitcast of the struct value to a pointer and died in mlir-opt.

LSP:
- track `own` declarations and moves per function and report
  "cannot use 'x': its value was moved" as an error diagnostic on later
  uses (severity 1, so editors paint it red).
- a rebinding clears the moved state.

Probe: `_U: own U = new U(1); _U2: own U = _U; print(_U2.a)` is clean;
`print(_U.a)` produces the move diagnostic and the compiler LifetimeError;
`_U3: U = _U2` produces the CastError. Self-host 110/110; ListTest,
GenericTest, GenericTest3/4, PtrOpProbe, HashLibTest, NewTest,
NewLifetimeTest, StructTest, EscapeOkTest and OopInheritTest pass.
2026-11-02 19:52:59 +08:00
TermiNexus 6fa98cf303 fix(lsp): resolve own/Box/Option types and color builtins
- Treat OwnT / OptionT / NullableT / Option / Box / BoxT / RawT as type
  wrappers in `_IsTypeWrapperName`, so `_U: own U` binds type U and
  `_U.a` resolves to the field (property color, hover, goto).
- Emit function semantic tokens for builtin calls (print, len, range,
  malloc, free, sizeof, ...), which have no declaration symbol; `print`
  was previously colored only by the grammar and lost it inside files the
  grammar could not scope.

Verified over a real semanticTokens/full request: `own` = keyword,
`print` = function, `_U.a` = property on the same line.
2026-11-02 19:01:40 +08:00
TermiNexus c95290f913 fix(lsp): color the own soft keyword
`own` is consumed by the annotation parser, so it never appears as a
symbol and the editor showed it uncolored. Emit a keyword semantic token
for the name `own`; identifiers/keywords otherwise stay with the TextMate
grammar.

This also unblocks symbol colors after an `own` annotation once the
server is rebuilt (the old binary could not parse `own` at all and lost
every later symbol).
2026-11-02 18:55:15 +08:00
TermiNexus c37b8dc52c feat(obs): drop default Box[T], add explicit own T ownership
Revert the implicit boxing pass: a bare `T` annotation is a raw value
again (`_U: U = new U(...)` fails with a pointer/value CastError), and
struct layouts are untouched.

`own T` is the explicit owned reference:
- resolves to one pointer layer (`Ptr(T)`); `own T?` stays nullable.
- a variable declared `own` is registered as an owned slot; a `new`
  initializer is always taken over (outside the NLL frame-confined
  predicate), so scope exit runs `__del__` + `free`.
- `new stack` is rejected for `own` (its storage dies with the frame).
- `own` bindings reject `None`; moves (`b: own T = a`) release the source
  slot.
- a bare identifier named `own` still works as a name/type.

`Box[T]`, `RawT[T]`, `Option[T]` / `T?` stay available as explicit forms.
LSP completion lists `own`.

Probe: `struct U { ..., n: own U }` resolves and `U.__sizeof__()` = 24;
`_U: own U = new U(1,2,3,4); _B: own U = _U` runs and `print(_U.a)` fails
with LifetimeError; `own U = None`, `own U = new stack U(...)` fail fast;
without `own` the old CastError returns; `own` works outside OBS too.
Self-host 110/110; ListTest, GenericTest, GenericTest3, GenericTest4,
PtrOpProbe, HashLibTest, NewTest, NewLifetimeTest, StructTest and
EscapeOkTest pass.
2026-11-02 18:26:00 +08:00
TermiNexus 1581aa0d84 feat(obs): default T to Box[T], explicit Box/RawT/Option, move analysis
Rename the per-file opt-in from `__cwa__` to `from __future__ import __obs__`
(internal StrictCWA becomes StrictOBS).

OBS type model:
- `Box[T]` / `BoxT[T]` is one owned indirection (`Ptr(T)`); a user generic
  class named Box keeps precedence.
- `RawT[T]` is the unboxed value type; primitives, arrays and pointers stay raw.
- `Option[T]` is the explicit spelling of the existing `T?` suffix
  (parser marker renamed to OptionT; NullableT still accepted) and resolves
  to a nullable pointer.
- Declaration annotations (locals, globals, parameters, returns) get an
  `AutoBoxT[...]` wrapper in OBS files before the vpi pass, so struct/class
  types become `Box[T]` while primitives stay raw; struct field declarations
  are left untouched, preserving the layout (`U.__sizeof__()` stays 16).
  Explicit `Box[...]`/`RawT[...]`/`Option[...]`/`T?` are not wrapped again.

Ownership:
- an OBS move (`b: T = a` where `a` owns a heap object) marks the source
  slot as released, so later uses (including `a.field`) fail fast; rebinding
  clears the state.

LSP: `Box`, `RawT` and `Option` are offered as builtin completions.

Probe: `_U: U = new U(1,2,3,4); _B: U = _U; print(U.__sizeof__())` prints 16 and
`print(_U.a)` fails with LifetimeError; Box[U]/Option[U]/U? declarations work;
`Box[U] = None` is rejected; `__cwa__` is rejected and `__obs__` accepted.
Self-host 110/110; ListTest, GenericTest, GenericTest3, GenericTest4,
PtrOpProbe, HashLibTest, NewTest, NewLifetimeTest, StructTest and
EscapeOkTest pass.
2026-11-02 18:10:34 +08:00
TermiNexus 8c72adde88 feat(gen): nullable T? types and non-null analysis in strict CWA files
A strict CWA file (`from __future__ import __cwa__`) now distinguishes
nullable from non-nullable types:

- `name: T?` resolves to `Ptr(T)` and records the slot as nullable
  (a per-function slot table, like the released-slot table).
- assigning `None` to a non-nullable declaration, plain assignment,
  annotated attribute/subscript target or return type fails fast with a
  TypeError; `T?` accepts it.
- class-name extraction unwraps `NullableT[...]`, so field lookup,
  method dispatch and `AnnotClassName` still see the class.

Non-strict files keep the previous behavior (`T* = None` is accepted);
`T?` also parses there as `Ptr(T)`, it just carries no enforcement.

Probe: strict `p: A? = None` / `p = new heap hlife A()` / `p = None`
works and null comparisons run; `p: A = None`, `p: A* = None`,
`q = None` on a non-nullable variable and `-> T*` + `return None` all
fail fast; `-> A?` and field `next: A?` work; the same `T* = None`
still compiles outside strict mode. Self-host 110/110; ListTest,
GenericTest3, PtrOpProbe, HashLibTest, NewTest, NewLifetimeTest,
StructTest and EscapeOkTest pass.
2026-11-02 15:47:36 +08:00
TermiNexus 5f981d7878 feat(gen): del obj reclaims a heap object (__del__ then free)
`del name` now destroys a class/struct instance:

- requires a pointer variable (T*) to a class/struct; value-typed T and
  non-struct types fail fast,
- rejects frame-allocated pointers (`new stack` / borrows) and repeat
  `del` of the same binding,
- calls `__del__` (MRO-aware, skipped for a null pointer) and then
  `free`s the object,
- cancels the automatic scope-exit release when the binding is a
  compiler-owned `slife` object, so the destructor and free still happen
  exactly once,
- marks the slot released: later reads, field access, subscript access
  and `&name` fail with a LifetimeError until the variable is rebound
  (rebinding clears the mark, as with willFree).

The released-variable diagnostic now names both causes (`del` or
`@life(willFree(...))`) and drops the obsolete `c.deref(p)` spelling.

Probe: hlife object prints `__del__` output once and exits 0; slife
object prints once (no second call at scope exit); field read/write and
plain use after `del` fail fast; rebound variable is usable again.
Self-host 110/110; ListTest, GenericTest3, PtrOpProbe, HashLibTest,
NewTest, NewLifetimeTest, StructTest and EscapeOkTest pass.
2026-11-02 15:15:20 +08:00
TermiNexus d9511a54ea fix(gen): keep new T() construction in the storage it allocated
For a plain struct with no field defaults and no constructor arguments,
the `A()` call path returned `zeroinitializer` directly instead of
constructing into the storage provided by `new`. Consequences for
`p: A* = new A()`:

- the malloc block was leaked,
- the struct value was materialized into an entry alloca by coercion,
  so `p` pointed at the frame,
- the slife scope-exit free then freed a stack address (heap corruption / 0xC0000374).

Reuse the constructor path whenever a `new` storage descriptor is
pending, so `_translate_struct_ctor` zero-initializes the heap block and
returns its pointer.

Also guard the compiler-owned `new ... slife` binding: the target slot
must be a pointer variable, otherwise a value-typed `T` target would make
the scope-exit free load the struct value and bitcast it to a pointer.

Probe: `new heap A()`, `new A()`, `new stack A()` and `new heap hlife A()`
on a field-only class all print the expected values and exit 0; the
generated IR frees the malloc pointer instead of a stack slot.
Self-host 110/110; ListTest, GenericTest3, PtrOpProbe and HashLibTest
pass; NewTest, NewLifetimeTest, StructTest, EscapeOkTest and
OopInheritTest pass. NewAstTest and GenericOopTest fail the same way on
the previous compiler (pre-existing).
2026-11-02 14:45:44 +08:00
TermiNexus 77e879ef18 fix(gen): correct two latent lookup bugs exposed by the new call order
- ExprGenOps called `_lookup_module_sha1` on ExprGenAttr, but the method
  lives on CallGenBase. With ExprGenOps now translated before the
  callee's stub/text.ll is available, the receiver lookup failed with
  "method return type cannot be obtained precisely":
  `cls=ExprGenAttr ... _lookup_module_sha1`. Use
  `self.Gen.CallGenBase._lookup_module_sha1`.
- VpiReader._lookup_vpi_ret_annot's generic-specialization fallback used
  `sha1_len` without declaring it. The new `list[int].set` return-type
  lookup reaches that fallback and failed with
  `NameError: undefined name 'sha1_len'`. Compute
  `sha1_len = strlen(src_sha1)` before the cache scan.

Self-host 110/110 (two generations); ListTest, GenericTest3, PtrOpProbe
and HashLibTest pass.
2026-11-02 13:48:28 +08:00
TermiNexus 9e0729b855 refactor: share VPI escape and willFree scanners
Replace the two near-identical VPI annotation line scanners with one marker scanner parameterized by annotation kind. Source-only change: 91 added, 123 deleted (net -32 lines).
2026-11-02 13:29:29 +08:00
TermiNexus 38a521fcd2 refactor: filter debug logs and deduplicate VPI loaders
Suppress DEBUG output at the default INFO level, share the VPI pre-registration walker, and share the .vpi read path between the annotation cache loaders. Source-only change: 53 added, 81 deleted (net -28 lines).
2026-11-02 13:24:30 +08:00
TermiNexus 634321a12b refactor: consolidate duplicate ViperC helpers
Merge repeated list lowering, directory scanning, SHA1-store writes, VPI lookup keys, type-parameter splitting, DefineT width normalization, and LSP helpers. Source-only change: 235 added, 793 deleted (net -558 lines).
2026-11-02 13:08:50 +08:00
TermiNexus 5b68732a94 refactor: remove dead ViperC compiler code
Drop unreachable struct/type helpers, the legacy wrapper-main and build pipeline paths, debug-only scaffolding, unused logging helpers/constants, and the no-op LSP signature map. Add the audit report for the remaining duplication and logging work.
2026-11-02 12:46:53 +08:00
TermiNexus 5a0d61a163 feat(gen): l[i] = v extends the list to include i
Element stores used to be a raw inline store, so a list filled with
`l[i] = array[i]` kept `__count__` at 0: `len()` was 0 and sorting/
iteration loops did nothing even though the raw slots held the data.

- `l[i] = v` and `l.set(i, v)` now call the real `list.set`
  (`_emit_list_set_call`), the same route `append` already uses for
  capacity/reallocation;
- `_list.set` grows the data buffer when `i >= capacity` (double, or
  `i + 1` for a far index), copies the live prefix, and sets
  `__count__ = i + 1` when `i >= count`; writing an existing index does
  not change the count;
- remove the duplicated inline set logic from the subscript path and
  both list-builtin branches.

Probe: `a[2] = 9` -> len 3; `a[20] = 7` -> len 21 and a[20] = 7;
`b[1] = "x"` -> len 2; the reporting project now sorts and prints
1 2 5 5 6 9; self-host 110/110; ListTest, GenericTest3, PtrOpProbe and
HashLibTest pass.
2026-11-02 11:34:48 +08:00
TermiNexus 10071cb58e fix(gen): reject reusing a loop variable with a different type
`for i in list[str]` binds i as i8*; a following `for i in range(3)`
reused that slot, so the integer index/comparison was written through an
i8* slot. With opaque pointers the store was accepted and the renderer
then saw a pointer-typed constant 3/6
(`[MLIR-FAIL] unsupported constant operand form`); without the renderer
check it silently corrupted the value.

- range loops accept any existing integer slot (the first width wins,
  as before) but reject non-integer slots;
- list / array / pointer / dict / OOP iteration require the exact
  element type, compared structurally (pointer depth + unwrapped base
  type, because opaque pointers print alike);
- a mismatch fails fast with TypeError naming the old and new types and
  suggesting a different variable name.

Probe: list[int] then range still works (prints 10/20/30, 10, 10);
list[str] then range now reports `loop variable 'i' is already declared
as '!llvm.ptr'; cannot reuse it as 'i32'`; the reporting project
compiles and runs; self-host 110/110; ListTest, GenericTest3, PtrOpProbe
and HashLibTest pass.
2026-11-02 11:21:19 +08:00
TermiNexus 91d300d45f fix(gen): list iteration loads the real element type
`for v in list[T]` allocated the loop variable as i8* and loaded each
element through i8*, so integer elements were treated as pointers. A
later reuse of the same name, e.g.

    for i in nums:  ...          # i becomes i8*
    for i in range(6): ...       # stores/compares 6 in the i8* slot

then materialized a pointer-typed constant 6 and failed the renderer
with `[MLIR-FAIL] unsupported constant operand form (type '!llvm.ptr',
value '6')`.

Resolve the element type from the list specialization
(`_list_elem_type_from_name`: list[int] -> i32, list[str] -> i8*) and use
it for both the loop-variable alloca and the element load; fail fast when
the specialization name cannot be resolved instead of silently loading a
pointer.

Probe: `for v in list[int]` prints 10/20/30 and sums 60; the reporting
project compiles and runs without the MLIR failure; self-host 110/110;
ListTest, GenericTest3, PtrOpProbe and HashLibTest pass.
2026-11-02 11:10:20 +08:00
TermiNexus 49683fc881 fix(gen): OOP __len__/__getitem__/methods beat pointer arithmetic
A pointer chain that reaches a class is a reference to the object, not an
array of objects. `arr: &list[int]*` (inside `arr` is `list[int]**`) used
to be treated as raw pointer arithmetic: `len(arr)` read the pointer slot,
`arr[i]` indexed it, and `arr.append(x)` passed the slot as self -- all
producing garbage or a runtime crash.

- add `_oop_self_ptr`: peel extra pointer levels down to Ptr(Struct);
- `len()` normalizes the receiver after resolving the class, so list
  inline `__count__` and class `__len__` receive the object pointer;
- subscript reads dispatch list / class `__getitem__` with the peeled
  receiver; raw pointer traversal remains the fallback when the chain
  does not reach a class (or the class has no `__getitem__`);
- the list builtin method branch and `_translate_method_call` peel the
  receiver, so `arr.append(x)` works through a reference;
- `is_list_subscript` unwraps every level for the assignment path, so
  `arr[i] = v` and tuple swaps on elements write the real list; value-type
  list variables keep their old path.

Probe: `&list[int]*` len/read/write/swap/append and `&Box*` custom
`__len__`/`__getitem__` all produce the expected values; self-host
110/110; ListTest, GenericTest3, PtrOpProbe and HashLibTest pass.
2026-11-02 10:50:35 +08:00
TermiNexus 83d309509b feat(gen): tuple-literal simultaneous assignment (a, b = b, a)
A tuple/list literal on the right of a multi-target assignment used to
return no value (`assignment rhs is None ... [kind=51]`). It is now
staged and assigned target by target:

- every RHS element is evaluated left to right and parked in its own
  temporary slot, so a swap always reads the old values;
- each target is then written by the ordinary single-target path, which
  keeps const checks, coercion, attribute/field stores, array and list
  element stores (`__setitem__`) and ownership handling unchanged;
- the temporary's life tag is copied from the staged expression, so
  pointer assignments keep their existing frame/heap lifetime;
- starred elements/targets and compiler-owned `new ... slife` objects
  fail fast (they cannot be staged without changing their semantics).

Probe: ints, array elements (`a[i], a[i+1] = a[i+1], a[i]`) and struct
fields (`p.x, p.y = p.y, p.x`) all swap correctly (exit 2304);
self-host 110/110; PtrOpProbe, HashLibTest, GenericTest3 and ListTest
still pass.
2026-11-02 10:29:05 +08:00
TermiNexus 94d8c0d151 fix(gen): keep &T auto-address correct when T is itself a pointer
`f(a: &T)` lowers to `T*` and takes the argument's address at the call
site. The old rule passed any pointer argument through unchanged, so
`bubble_sort(nums)` with `arr: &list[int]*` (T = list[int]*) sent
`list[int]*` where `list[int]**` was declared and failed with a pointer
level 1 -> 2 CastError.

- _apply_auto_addr_arg compares the argument's structural pointer depth
  with the parameter type instead of "is a pointer". For a Name whose
  own alloca already has the parameter depth, the alloca is passed
  directly: the `&obj` fold on an object-pointer variable drops one
  level, which is wrong for `&T` with a pointer T.
- translate_call_arg falls back to the definition-side vpi annotation
  when the same-module table misses, so a cross-module callee that has
  not been body-translated yet still gets its &T modes.
- _coerce_args_to_def_params uses the injected declare's parameter type
  when the vpi text cannot resolve it (e.g. a generic class name), and
  passes that type to the auto-address transform.
- a null literal passed to `&T` keeps passing through (no lvalue).

Probe: `bubble_sort(nums)` sorts through `&list[int]*`; `bump(x)` and
`bump(p)` with `p: int*` both increment x (value addressed, pointer
passed through). Self-host 110/110; GenericTest3, ListTest, PtrOpProbe
pass; EscapeTest still reports its expected LifetimeError.
2026-11-02 09:46:31 +08:00
TermiNexus f57a7d962c feat(gen): generic calls with explicit args and pointer-parameter inference
Three gaps kept `mod.f[T](x)` from compiling:

- get_func_name now unwraps a Subscript callee to its base name, so an
  expression statement like `f[int](x)` / `mod.f[int](x)` reaches the
  normal call path instead of failing name resolution. Generic class
  constructors and type subscripts still return None, keeping their
  dedicated paths.
- the generic-call dispatcher resolves `Attribute` bases inside the
  subscript (`temp.array2list[int]`) to the defining module's SHA1, and
  treats it as a module-qualified template lookup.
- inference understands pointer/array annotations around the type
  parameter: `_annotation_refers_type_param` walks PtrT/ArrayT wrappers
  and tuple slices, and inference peels as many pointer/array layers off
  the argument as the parameter annotation wraps around T, so
  `array2list(l, ...)` with `array: T*` and `l: []int` infers int.

Self-host: 110/110 with the new compiler building itself; GenericTest,
GenericTest3, GenericTest4, VectorGenericTest and ListTest pass.
2026-11-02 09:07:32 +08:00
TermiNexus 40e3388dc3 feat(gen): per-file strict CWA mode via from __future__ import __cwa__
A file opts into the strict NAFTAG lifetime mode with a leading

    from __future__ import __cwa__

and only that file changes: every other module keeps the legacy @life
escape analysis, so existing code is untouched.

- detect_strict_cwa publishes the flag per file (reset before each
  translation, also visible to the .vpi pass); the directive must be the
  file's first statement, a module docstring may precede it
- the directive is not a module import: import translation returns early
  and the .vpi writer skips it, so importers never look for `__future__`
- strict files reject @life/@willFree with LifetimeError; the OBS
  call-site contracts are the only boundary syntax there
- Translator.StrictCWA carries the mode for the current file
2026-11-01 19:03:56 +08:00
TermiNexus 63f3e053b2 refactor(stub): use literal-container membership in the vpi scanner
ViperC is now self-hosted, so its own source can use `in (...)`/`[...]`/`{...}`.
The vpi scanner compared raw byte values (0x22/0x27/0x28/...) and now reads

    if ch in ('"', '\''): quote = ch
    elif ch in ('(', '[', '{'): depth += 1

which is the same lowering the compiler emits for user code (OR of equality
tests, string literals through strcmp).
2026-11-01 00:16:04 +08:00
TermiNexus 83d15289ef feat(vpc): literal-container membership, one vpi scanner, extern/default fixes
- `x in (a,b,c)` / `[..]` / `{..}` lowers to an OR of equality tests (string
  literals via strcmp) and `not in` negates it; literal containers used to
  silently evaluate to false
- add VpiText (ParenEnd/ParamSpans/ParamCount/HasVararg/FindDefaultEq) and
  drop the five hand-written copies of the parameter scanner; escape skips no
  longer jump past the parameter-list end
- fill omitted arguments from recorded defaults before any zero-fill, and fail
  fast when no default is recorded (memcpy keeps its intrinsic lowering)
- resolve package-qualified `extern def` calls without a SHA1 prefix:
  explicit allow_star=1, rel-path module fallback, raw vpi bare-declaration
  scan, and `extern def`/`export def` flags honoured during declare injection
- fix exact-size read_all usage in VpiStub.Load/Ensure and the vpi scanner
2026-11-01 00:05:44 +08:00
TermiNexus 84f11a69e7 refactor(callgen): annotate string values as char* (mechanical pass)
One-token annotation change across the call generators: str -> char*.
No logic changes; the same pass also rewrote literal "string" in comments,
which is why this is committed as a checkpoint rather than as behaviour.
2026-10-31 16:50:55 +08:00
TermiNexus cd78edd702 chore: ignore the prebuilt libs directory
libs/libLLVM-22.dll is a 155 MB downloadable artifact; keeping it out of the
index keeps clones and diffs small.
2026-10-31 16:30:32 +08:00
TermiNexus 759c810cb2 chore: ignore every __vpcbuild*__ build directory
__vpcbuild__/ and __vpcbuild_*/ covered only two spellings; builds also create
__vpcbuild3__, __vpcbuild6__ and similar, so collapse them into one glob.
2026-10-31 16:28:10 +08:00
TermiNexus 4bf04c2452 feat(vpc): binary .vpb stub companion and str->char* migration in call gen
- add VpiStub: fixed-layout blob written next to every .vpi (dual write at all
  writers, Ensure() backfills a missing or stale companion on cache reuse)
- serve arity/signature queries for top-level functions from the blob, with a
  silent fallback to the text scanner (methods, varargs, duplicates, corrupt file)
- prefer the injected declare over the text arity in the cross-module check
- CDefine/type text indexes and in-place SSA renumbering and bulk appends
- WIP: char* conversion in CallGenBase and CallGenLeafKit
2026-10-31 16:25:18 +08:00
TermiNexus 451d89b39a feat(vpf): default to level 3 (counts + time + edges) when --spend-file is given 2026-10-30 22:26:02 +08:00
TermiNexus 91e188c539 feat(vpc): vprofile instrumentation (--spend-file/--vpf) + verbatim --build-self forwarding
--spend-file <path> instruments every compiled function with
vpf_enter(id, name) / vpf_leave(id); main additionally calls
vpf_install(path, level). includes/vprofile.vp keeps the tables (no
allocation on the hot path), times with QPC and writes the report from
atexit, so normal return and exit() both produce it.

  --vpf 0 off | 1 call counts | 2 +per-function time (default) |
  3 +caller/callee edges

*A report path is resolved against the output exe directory, so
  ../prof.txt and absolute paths behave as documented regardless of the
  process cwd.
*Instrumented include objects use their own cache suffix
  (.binary.vpf<N>, stacked with the asan/traceback suffixes) and that
  cache is cleared once when profiling is enabled.
*StubMergerBuild force-adds the vprofile runtime to the reachable set;
  FuncGenBase emits a declare on demand because the instrumented module
  usually does not import vprofile.
*--repl is rejected: the fragment DLL is loaded at runtime and does not
  link the runtime.

--build-self no longer keeps a whitelist: SelfBuild.CollectForwardedArgs
drops only --build-self / --project / --build-dir and forwards every
other argument verbatim, so a self-build can enable the same modes as a
normal build.
2026-10-30 22:24:01 +08:00
TermiNexus c9d33bc202 build(phaseb): move llvm-link + opt for raw-IR modules into ircore
Raw-IR modules (`__llvmir__` / `__mlir__`) were the last common case that still needed
child processes: llvm-link merged the `.raw.ll` sidecar into the translated module, then
`opt -passes=always-inline,globaldce` inlined the raw bodies. Both now run inside the DLL:

- new `ircore_mlir_to_obj_raw(mlir, len, raw_ir, raw_ir_len, ...)`: the sidecar is parsed
  into the same LLVM context, merged with LLVMLinkModules2, then run through
  always-inline,globaldce via LLVMRunPasses (IRCORE_FLAG_INLINE covers the `.inline`
  marker case, which has no sidecar)
- the worker reads the sidecar itself with stdlib buffers (MemBuddy is not thread-safe),
  so `.ll.pre`, `.ll.link` and `.ll.inline` intermediates are gone as well
- LLVMLinkModules2 is exported by libLLVM-22.dll but no longer declared in the llvm-c
  headers we build against, so it is declared by hand (stable C ABI)
- PhaseB falls back to the legacy flow only for --phase2command now

Verified: RawIRTest runs through ircore with no `.mlir.opt`/`.ll.pre`/`.ll.link`/
`.ll.inline` leftovers and prints the same results as the legacy build; the FFI suite
gained a raw-merge check (the sidecar symbol is undefined without it, linked and inlined
away with it); TestFBProject (also uses __llvmir__) and 12 representative projects pass.
2026-10-30 20:49:13 +08:00
TermiNexus f8368d5e47 build(phaseb): run --traceback through ircore too
--traceback only needed one thing from llc (the per-module part): -frame-pointer=all. The
codegen flags are now a parameter of the FFI instead of a reason to fall back:

- ircore_mlir_to_obj_opts / ircore_llvmir_to_obj_opts take IRCORE_FLAG_* bits:
  ASan instrumentation and frame pointers
- frame pointers are applied by adding the "frame-pointer"="all" function attribute to
  every definition (what clang emits for -fno-omit-frame-pointer; the backend honours it
  and it overrides the target default that llc's flag would have set), after ASan ran so
  its generated helpers are covered too
- PhaseB calls the opts entry point with flags = asan|traceback, so neither mode starts a
  child process anymore; the legacy command flow is unchanged for --phaseb-legacy,
  --phase2command and raw-IR modules

Verified: a --traceback build has no .mlir.opt leftovers, the object carries the same rbp
prologues as `llc -frame-pointer=all`, the <exe>.stackmap is produced (24616 bytes, same
as the legacy build) and the executable runs; --asan still reports the same heap overflow;
12 representative Test projects pass with the deployed compiler.
2026-10-30 20:33:39 +08:00
TermiNexus eba9e9b6cc build(phaseb): run ASan instrumentation in-process too
--asan used to force the legacy PhaseB because the instrumentation step was a separate
`opt -passes=forceattrs,asan -force-attribute=sanitize_address -asan-globals=false` child
process. ircore.dll now exposes ircore_mlir_to_obj_asan / ircore_llvmir_to_obj_asan, which
run the same passes through LLVM's C pass builder (LLVMRunPasses) between MLIR lowering and
codegen, so ASan builds also avoid the three child processes.

- the two cl::opt values are parsed once (std::call_once) before the first pass run
- PhaseB loads the asan entry point together with the others and calls it when _asan_mode
  is set; the legacy `opt` path stays for --phaseb-legacy and is unchanged
- --traceback (llc -frame-pointer=all) and raw-IR modules (llvm-link/opt) still take the
  legacy flow

Verified on a real VKernel module: the ircore-built object and the `opt`-instrumented one
reference the same ASan runtime symbols (69 __asan_report / 4 asan.module_ctor), and a
deliberate heap overflow is reported identically by both builds.
2026-10-30 20:09:00 +08:00
TermiNexus ef4582dcfc build(phaseb): compile modules in-process through ircore.dll
PhaseB spawned three child processes per module (mlir-opt, mlir-translate, llc), and the
process startup dominated the cost: a 0.3 KB module took ~170 ms, of which ~150 ms was
just starting the tools; the 59-module VKernel set cost 18.4 s CPU (3.3 s wall on 16
workers).

ircore.dll (IRCore) does MLIR parse + verify + LLVM IR translation + codegen in a single
call. PhaseB now uses it from the worker threads:

- loaded at runtime with LoadLibraryA/GetProcAddress, not through the import table, so the
  compiler still starts when the DLL is missing; one retry points the loader at clang64's
  bin directory because ircore.dll needs that exact LLVM build
- falls back to the legacy flow automatically (with a warning) when the DLL cannot load
- `--phaseb-legacy` forces the legacy backend; the old command path is kept intact
  (compile_ll_to_obj / init_llc_task / _llc_task_worker / run_llc_parallel)
- ASan instrumentation, raw-IR merging (llvm-link/opt), --traceback frame pointers and
  --phase2command keep using the external tools
- new failure stage LLC_STAGE_IRCORE so a failure is attributed to the FFI call

Measured: 59 VKernel modules 18.4 s -> 4.8 s CPU, 3.3 s -> 1.6 s wall; AstTest
--rebuild-includes 13.6 s -> 11.3 s; BuddyTest 4.8 s -> 2.9 s; ViperC self-host 125 s ->
119 s (PhaseA dominates it); 41 Test projects pass, plus the 5 pre-existing failures.
2026-10-30 18:42:55 +08:00
TermiNexus eda8e4465f refactor(codegen): one body pipeline for module functions and class methods
Module-level functions and class methods each carried their own copy of "resolve the
return type", "create the parameter allocas" and "emit the implicit epilogue". The copies
drifted: an unannotated method was still typed i32 by the class-method body path while its
declaration said void, so the epilogue emitted `llvm.return 0 : i32` inside a void function
and the MLIR verifier rejected it ("'llvm.return' op expected no operands", Scheduler._yield).

- FuncGenBase.resolve_func_ret_ty: the single return-type policy (annotation -> storage
  marker -> __init__/__new__ hooks -> unannotated = procedure void -> fail-fast). Used by
  the forward-declare pass, the body pass, class methods and generic specializations
- FuncGenBase.param_llvm_ty: the single parameter mapping (CVoid skipped, bare class ->
  Ptr(struct), ArrayT -> element pointer), used by every signature and alloca site
- FuncGenBase.emit_func_epilogue: the single implicit-return implementation (procedure ->
  ret void, unreachable merge, fail-fast for an annotated fall-through), shared by module
  functions, methods and nested functions
- FuncGenBase.translate_func_body: the shared body pipeline (scope entry, self/parameter
  storage, *args va_list, try/except out-params, pre-scan, capture preparation, slife,
  statements, epilogue, state restore). ClassGenVTable._translate_method now only handles
  method naming, vtable/overload registration and add_param
- the same policy reaches cross-module and generic declares: unannotated vpi functions and
  methods declare void, generic specialization declares use the shared resolver, and the
  list.append call sites emit a void call
- method parameters now carry the const / volatile / pointee-volatile / pure markers that
  the module-level path always had
- FuncGenMixin: the two dry-run helpers that return ints are annotated, as the language
  now requires (an unannotated def is a procedure)

Verified: 41 Test projects pass (MethodProcTest, CrossModuleSigTest, RawPtrParamTest,
AstTest, ListTest, OopInheritTest, ...), ViperC self-hosts with the new compiler, and the
6 tests that still fail reproduce identically with the previous compiler.
2026-10-29 19:37:55 +08:00
TermiNexus 3b3cd42d1c fix(codegen): a procedure never emits a value return
Unannotated functions were typed by scanning the body, which inferred i32, so falling off
the end emitted `llvm.return 0 : i32` inside a function declared void. The MLIR verifier
rejects that ("expected no operands"), which is what broke Scheduler._yield.

- an unannotated `def` is void in both the forward declare and the body, so the implicit
  epilogue emits `ret void`
- translate_return treats a procedure (no annotation, `-> None`, or a void-typed function) as
  void: the expression is evaluated for its side effects and `ret void` is emitted. Bare
  `return`, `return f()` where f is void, and `return <None value>` are legal and never carry
  a value
- body_has_value_return accepts `return f()` and `return <None define>` in a procedure
- `define X = None` is a named None: registered as the string sentinel None, materialized as
  a None value at use sites, and exported through the vpi
2026-10-29 18:13:06 +08:00
TermiNexus ffe31cc6e3 fix(call): pass the class segment when resolving a cross-module method return type
The cross-module return-type resolution derived only {sha} and the last segment from {sha}.Class.method, then queried the vpi cache and the dependency text.ll with no class name. Both key methods by class, so a class-level function without a return annotation (Scheduler._yield) missed both lookups and hit the fail-fast 'Cannot obtain the cross-module function return type precisely' path. Derive the middle segment and pass it as the class when it names a registered class; module functions ({sha}.submod.func) keep the bare-name lookup.
2026-10-29 17:48:59 +08:00
TermiNexus f5c5e068ae fix(vpi): keep the bare ExportT declare when another module has the same name
Injecting a dependency's module function deduped through a suffix match, so {other_sha}.init already in the module suppressed the bare @init declare of this module's export. Calls to tty.init(...) then suffix-matched the other module's init (1 parameter) and failed the arity check; the call-site module check also accepted a mangled symbol from another module whenever this module exported any function with that name.

- dedup a bare ExportT/State symbol by exact name only (VpiReader._inject_module_declare)

- accept a foreign-module binding only for bare symbols (the export/state form) or a re-export owner (CallGenMixin)
2026-10-29 17:33:31 +08:00
TermiNexus 825fef8f41 fix(vpi): count a static method's first parameter as an argument
LookupSignDefaultsByName subtracted one receiver slot from every class-level def, but the vpi keeps the declared parameter list: a @staticmethod's first parameter is a real argument. Scheduler.exit_process(pid: int) therefore reported 0 parameters and the call-site arity check rejected exit_process(p.pid). Subtract the receiver only when the first parameter is actually named self/cls.
2026-10-29 17:19:00 +08:00
TermiNexus 8effc94827 fix(call): bind mod.func() to the module function, not a same-named static method
A suffix lookup for func() also matches {sha}.Class.func symbols. The no-self exception added for sched.Scheduler._yield() was too broad: it kept such a method hit for a module-qualified call like proc.current(), returning Process* where ProcessManager.current() -> int is meant (proc.current() is a module-level function). Keep the namespaced-function exception only when the receiver path itself names the class (mod.Class.func()); bare and module-qualified calls reject method-shaped suffix hits and resolve the module symbol through the vpi.
2026-10-29 17:10:17 +08:00
TermiNexus 1f067aa58f fix(call): accept void methods and rawptr arguments at the call boundary
Two follow-ups to the cross-module signature check:

- A method without a return annotation is void, like a module-level `def`. The vpi
  return-annotation cache recorded it as a hardcoded i32, so callers invented an i32 return
  for a void method. It now records `void` for non-extern declarations (methods included).
- A class-level function declared without `self` (`Class.func()`) is a namespaced plain
  function, not a method bound to a receiver. The module-qualified call path rejected such
  symbols as method bindings, which then fell through to the external-C whitelist and failed
  on a void return (`Scheduler._yield`).
- A pointer argument passed to an integer-typed parameter is converted at the call boundary
  instead of failing the implicit pointer-to-integer rule. A `rawptr` value whose callee
  parameter degraded to uint64 (stale/mis-typed stub) now reaches the callee with its address
  instead of aborting with a CastError.
2026-10-29 16:43:35 +08:00
TermiNexus 4c34e396a4 fix(call): align cross-module calls with the callee symbol's signature
mlirlite.build_call composes the llvm.call function type from the argument values and the
caller-supplied return type; it never consults the callee. Declares are synthesized the same
way (_ensure_method_declare appends missing params from the caller's own argument values and
never compares return types), so nothing verified that a call site agreed with the definition.
Two modules could therefore emit incompatible calls for the same symbol, which only the MLIR
verifier rejected later:

    llvm.func @MapHHDM(i64, i64, i64)                 (definition, void)
    llvm.call @MapHHDM(i64, i64, i32) -> i32          (caller)

- after the declare/definition exists, conform every fixed argument to its parameter type and
  emit the call with the symbol's return type (_conform_call_args / _call_symbol_ret_ty)
- fail fast when a cross-module call site resolves a return type the symbol disagrees with,
  instead of emitting both and letting mlir-opt report an operand type mismatch
- record a module-level `def` without a return annotation as `void` in the vpi return-annotation
  cache (previously a hardcoded i32, so callers invented an i32 return for void exports);
  extern/state C declarations and methods keep the legacy default
2026-10-29 15:53:43 +08:00