From 9d3a019670d41c83d4cff9cb3454987c5b309909 Mon Sep 17 00:00:00 2001 From: agra Date: Mon, 22 Jun 2026 11:55:01 +0300 Subject: [PATCH] feat: #get property accessors (no-paren method-as-field) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A method declared `name :: (self: *T) -> R #get => expr;` is invoked via no-paren field syntax (`obj.name`) instead of `obj.name()`. It is an ordinary method (registered `Type.method`, flagged is_get); field-access lowering and inference dispatch to it when no real field of that name exists, by synthesizing a no-arg `obj.name()` call routed through the normal call path (so receiver address-of and generic binding are reused). - Lexer/token: `#get`. Parser: parsed after the return type in parseFnDecl; hasFnBodyAfterArrow treats it as a body marker so struct-body methods parse. - Resolution: getAccessorFor handles a generic-struct instance and a plain struct. A REAL field of the same name wins (a getter never shadows stored data). An explicit postfix-deref receiver (`p.*.getter`) dispatches on the inner pointer so it takes the working auto-deref path. - Works on plain + generic structs (incl. getters returning the type param), in expressions/conditions/args/loop-bounds, chained, and via a pointer receiver. Examples: types/0196 (basic) + types/0197 (stress). Known narrow limitations (clean errors / workarounds, not silent): a getter RESULT used directly as a method/getter receiver (`o.gi.dbl`) errors — bind it to a local first; a getter named `len`/`ptr` returning non-i64 mis-infers (the .len/.ptr builtin-field shortcut). --- .../types/0196-types-get-property-accessor.sx | 31 ++++++++++ .../types/0197-types-get-property-stress.sx | 51 +++++++++++++++++ .../0196-types-get-property-accessor.exit | 1 + .../0196-types-get-property-accessor.stderr | 1 + .../0196-types-get-property-accessor.stdout | 5 ++ .../0197-types-get-property-stress.exit | 1 + .../0197-types-get-property-stress.stderr | 1 + .../0197-types-get-property-stress.stdout | 10 ++++ src/ast.zig | 4 ++ src/ir/expr_typer.zig | 10 ++++ src/ir/inst.zig | 6 ++ src/ir/lower.zig | 1 + src/ir/lower/decl.zig | 2 + src/ir/lower/expr.zig | 56 +++++++++++++++++++ src/ir/lower/generic.zig | 1 + src/ir/lower/pack.zig | 1 + src/lexer.zig | 1 + src/lsp/server.zig | 1 + src/parser.zig | 10 ++++ src/token.zig | 1 + 20 files changed, 195 insertions(+) create mode 100644 examples/types/0196-types-get-property-accessor.sx create mode 100644 examples/types/0197-types-get-property-stress.sx create mode 100644 examples/types/expected/0196-types-get-property-accessor.exit create mode 100644 examples/types/expected/0196-types-get-property-accessor.stderr create mode 100644 examples/types/expected/0196-types-get-property-accessor.stdout create mode 100644 examples/types/expected/0197-types-get-property-stress.exit create mode 100644 examples/types/expected/0197-types-get-property-stress.stderr create mode 100644 examples/types/expected/0197-types-get-property-stress.stdout diff --git a/examples/types/0196-types-get-property-accessor.sx b/examples/types/0196-types-get-property-accessor.sx new file mode 100644 index 00000000..377a430a --- /dev/null +++ b/examples/types/0196-types-get-property-accessor.sx @@ -0,0 +1,31 @@ +// `#get` property accessors — a method marked `#get` is invoked via no-paren +// field syntax (`obj.name`) rather than `obj.name()`. Works on plain structs +// and generic-struct instances (including a getter whose return type is the +// instance's type parameter), and the accessed value types correctly in +// expressions and bindings. +#import "modules/std.sx"; + +Counter :: struct { + n: i64; + doubled :: (self: *Counter) -> i64 #get => self.n * 2; +} + +Vec :: struct ($T: Type) { + data: []T; + count :: (self: *Vec(T)) -> i64 #get => self.data.len; // returns i64 + first :: (self: *Vec(T)) -> T #get => self.data[0]; // returns the type param T +} + +main :: () -> i64 { + c : Counter = .{ n = 21 }; + print("doubled={}\n", c.doubled); // 42 + x := c.doubled; // inference: x is i64 + print("x+1={}\n", x + 1); // 43 + + a : [3]i64 = .[10, 20, 30]; + v : Vec(i64) = .{ data = a[0..3] }; + print("count={}\n", v.count); // 3 + print("first={}\n", v.first); // 10 + print("sum={}\n", v.count + v.first); // 13 + return 0; +} diff --git a/examples/types/0197-types-get-property-stress.sx b/examples/types/0197-types-get-property-stress.sx new file mode 100644 index 00000000..00e08f89 --- /dev/null +++ b/examples/types/0197-types-get-property-stress.sx @@ -0,0 +1,51 @@ +// `#get` property accessor — stress coverage: getters returning scalars / bool +// / a type param / a struct / a pointer; getters used as a field read, in +// expressions, conditions, call args, loop bounds, chained, and nested +// (a getter calling another getter); on a pointer receiver and via auto-deref; +// a getter still callable with explicit parens; multiple getters per type. +#import "modules/std.sx"; + +Pt :: struct { x: i64; y: i64; } + +Box :: struct { + v: i64; + is_pos :: (self: *Box) -> bool #get => self.v > 0; // bool + neg :: (self: *Box) -> i64 #get => -self.v; // scalar + origin :: (self: *Box) -> Pt #get => .{ x = self.v, y = self.v }; // struct + // nested: a getter that reads another getter + twice_neg :: (self: *Box) -> i64 #get => self.neg + self.neg; +} + +Holder :: struct ($T: Type) { + data: []T; + n :: (self: *Holder(T)) -> i64 #get => self.data.len; // i64 + head :: (self: *Holder(T)) -> T #get => self.data[0]; // type param T + tailp :: (self: *Holder(T)) -> *T #get => @self.data[self.data.len - 1]; // pointer +} + +main :: () -> i64 { + b : Box = .{ v = 7 }; + print("is_pos={} neg={} twice_neg={}\n", b.is_pos, b.neg, b.twice_neg); // true -7 -14 + if b.is_pos { print("positive\n"); } // condition + o := b.origin; // struct-returning getter + binding + print("origin=({},{})\n", o.x, o.y); // (7,7) + print("paren neg()={}\n", b.neg()); // explicit-paren call still works + + a : [4]i64 = .[100, 200, 300, 400]; + h : Holder(i64) = .{ data = a[0..4] }; + print("n={} head={}\n", h.n, h.head); // 4 100 + print("tail={}\n", h.tailp.*); // 400 (pointer getter, deref) + print("n+head={}\n", h.n + h.head); // 104 (in expression) + + // getter as a loop bound + call arg + total := 0; + k := 0; + while k < h.n { total = total + h.data[k]; k = k + 1; } + print("loop-bound total={}\n", total); // 1000 + + // via a pointer receiver (auto-deref) and an explicit postfix deref + pb := @b; + print("via ptr is_pos={}\n", pb.is_pos); // true + print("via deref is_pos={}\n", pb.*.is_pos); // true (p.*.getter) + return 0; +} diff --git a/examples/types/expected/0196-types-get-property-accessor.exit b/examples/types/expected/0196-types-get-property-accessor.exit new file mode 100644 index 00000000..573541ac --- /dev/null +++ b/examples/types/expected/0196-types-get-property-accessor.exit @@ -0,0 +1 @@ +0 diff --git a/examples/types/expected/0196-types-get-property-accessor.stderr b/examples/types/expected/0196-types-get-property-accessor.stderr new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/examples/types/expected/0196-types-get-property-accessor.stderr @@ -0,0 +1 @@ + diff --git a/examples/types/expected/0196-types-get-property-accessor.stdout b/examples/types/expected/0196-types-get-property-accessor.stdout new file mode 100644 index 00000000..d055621f --- /dev/null +++ b/examples/types/expected/0196-types-get-property-accessor.stdout @@ -0,0 +1,5 @@ +doubled=42 +x+1=43 +count=3 +first=10 +sum=13 diff --git a/examples/types/expected/0197-types-get-property-stress.exit b/examples/types/expected/0197-types-get-property-stress.exit new file mode 100644 index 00000000..573541ac --- /dev/null +++ b/examples/types/expected/0197-types-get-property-stress.exit @@ -0,0 +1 @@ +0 diff --git a/examples/types/expected/0197-types-get-property-stress.stderr b/examples/types/expected/0197-types-get-property-stress.stderr new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/examples/types/expected/0197-types-get-property-stress.stderr @@ -0,0 +1 @@ + diff --git a/examples/types/expected/0197-types-get-property-stress.stdout b/examples/types/expected/0197-types-get-property-stress.stdout new file mode 100644 index 00000000..5a368700 --- /dev/null +++ b/examples/types/expected/0197-types-get-property-stress.stdout @@ -0,0 +1,10 @@ +is_pos=true neg=-7 twice_neg=-14 +positive +origin=(7,7) +paren neg()=-7 +n=4 head=100 +tail=400 +n+head=104 +loop-bound total=1000 +via ptr is_pos=true +via deref is_pos=true diff --git a/src/ast.zig b/src/ast.zig index 8ba5c108..ab81b699 100644 --- a/src/ast.zig +++ b/src/ast.zig @@ -192,6 +192,10 @@ pub const FnDecl = struct { /// OPT-IN: only `is_ufcs` fns and `ufcs` aliases dispatch; a plain /// fn is callable directly or via `|>` only. is_ufcs: bool = false, + /// `name :: (self: *T) -> R #get => expr;` — a no-paren property accessor. + /// Invoked via field syntax (`obj.name`) when no real field matches, rather + /// than as a `obj.name()` call. Takes only the `self` receiver. + is_get: bool = false, }; pub const Param = struct { diff --git a/src/ir/expr_typer.zig b/src/ir/expr_typer.zig index b8782766..39c97fec 100644 --- a/src/ir/expr_typer.zig +++ b/src/ir/expr_typer.zig @@ -259,6 +259,16 @@ pub const ExprTyper = struct { for (fields) |f| { if (f.name == field_name_id) return if (is_opt_chain) self.l.optionalOfFlattened(f.ty) else f.ty; } + // `#get` property accessor: type as the accessor's return + // type. Resolve via the synthesized no-arg call so generic + // bindings (e.g. a `List(T)` getter returning `T`) resolve + // exactly as the lowering path's call does. + if (self.l.getAccessorFor(obj_ty, fa.field) != null) { + const callee_node = Node{ .data = .{ .field_access = fa }, .span = node.span }; + const syn_call = ast.Call{ .callee = @constCast(&callee_node), .args = &.{} }; + const rt = self.l.callResolver().resultType(&syn_call); + return if (is_opt_chain) self.l.optionalOfFlattened(rt) else rt; + } } return .unresolved; }, diff --git a/src/ir/inst.zig b/src/ir/inst.zig index 12480a5d..c96a9abc 100644 --- a/src/ir/inst.zig +++ b/src/ir/inst.zig @@ -650,6 +650,12 @@ pub const Function = struct { /// from `.c`. is_naked: bool = false, + /// `#get` property accessor (ast.FnDecl.is_get). Registered as an ordinary + /// method, but ALSO reachable via no-paren field syntax (`obj.name`) when no + /// real field matches — field-access lowering/inference calls it with the + /// receiver as `self`. + is_get: bool = false, + pub const Param = struct { name: StringId, ty: TypeId, diff --git a/src/ir/lower.zig b/src/ir/lower.zig index ba82f29a..3aa1f0cf 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -1967,6 +1967,7 @@ pub const Lowering = struct { pub const lowerStructLiteral = lower_expr.lowerStructLiteral; pub const lowerInitBlock = lower_expr.lowerInitBlock; pub const getStructFields = lower_expr.getStructFields; + pub const getAccessorFor = lower_expr.getAccessorFor; pub const fixupMethodReceiver = lower_expr.fixupMethodReceiver; pub const getStructTypeName = lower_expr.getStructTypeName; pub const builtinTypeName = lower_expr.builtinTypeName; diff --git a/src/ir/lower/decl.zig b/src/ir/lower/decl.zig index 9cf2c9be..74c92c28 100644 --- a/src/ir/lower/decl.zig +++ b/src/ir/lower/decl.zig @@ -2322,6 +2322,7 @@ pub fn declareFunction(self: *Lowering, fd: *const ast.FnDecl, name: []const u8) func.is_variadic = is_variadic; func.has_implicit_ctx = wants_ctx; func.is_naked = (fd.abi == .naked); + func.is_get = fd.is_get; self.extern_name_map.put(name, c_name) catch {}; self.fn_decl_fids.put(fd, fid) catch {}; return; @@ -2336,6 +2337,7 @@ pub fn declareFunction(self: *Lowering, fd: *const ast.FnDecl, name: []const u8) func.is_variadic = is_variadic; func.has_implicit_ctx = wants_ctx; func.is_naked = (fd.abi == .naked); + func.is_get = fd.is_get; if (weldedCompilerFn(self, fd, name)) func.compiler_welded = true; // A BODIED `abi(.compiler)` function is a user compiler-domain function (e.g. a // post-link callback): the VM runs its sx body, but it NEVER runs in the binary diff --git a/src/ir/lower/expr.zig b/src/ir/lower/expr.zig index 7bcfdae0..51397de4 100644 --- a/src/ir/lower/expr.zig +++ b/src/ir/lower/expr.zig @@ -637,6 +637,30 @@ pub fn lowerFieldAccess(self: *Lowering, fa: *const ast.FieldAccess, span: ast.S return self.lowerObjcDefinedStateFieldRead(fa.object, info); } + // `#get` property accessor: `obj.field` where `field` is a `#get` method + // dispatches as a no-paren method call (`obj.field()`). Detected via type + // info only (no lowering) so the receiver is not evaluated twice — the + // synthesized call re-lowers `fa.object` and handles the receiver + // address-of + any generic binding itself. + { + var recv_ty = self.inferExprType(fa.object); + if (!recv_ty.isBuiltin()) { + const di = self.module.types.get(recv_ty); + if (di == .pointer) recv_ty = di.pointer.pointee; + } + if (self.getAccessorFor(recv_ty, fa.field) != null) { + // For an explicit-deref receiver `(*p).getter`, dispatch on the + // inner pointer `p` (`p.getter`, auto-deref) — semantically identical + // and it takes the working receiver path (the synthesized call on a + // `.deref_expr` receiver otherwise mis-lowers the `*self` address). + var recv_fa = fa.*; + if (fa.object.data == .deref_expr) recv_fa.object = fa.object.data.deref_expr.operand; + const callee_node = Node{ .data = .{ .field_access = recv_fa }, .span = span }; + const syn_call = ast.Call{ .callee = @constCast(&callee_node), .args = &.{} }; + return self.lowerCall(&syn_call); + } + } + var obj = self.lowerExpr(fa.object); var obj_ty = self.inferExprType(fa.object); @@ -825,6 +849,38 @@ pub fn vectorLaneIndex(field: []const u8) ?u32 { return null; } +/// A `#get` property accessor for `obj_ty.field`, or null. A `#get` method is a +/// normal method (registered `Type.method`) marked `is_get`; it is reachable via +/// no-paren field syntax. Handles a generic-struct instance (`List(i64).len`) +/// and a plain struct (`Foo.bar`). `ty` must be the dereferenced (non-pointer) +/// receiver type. +pub fn getAccessorFor(self: *Lowering, ty: TypeId, field: []const u8) ?*const ast.FnDecl { + if (ty.isBuiltin()) return null; + // A REAL field of this name wins over a same-name `#get` (a getter must not + // shadow stored data on the read path). If the struct genuinely declares the + // field, this is not a property access. + const field_id = self.module.types.internString(field); + for (self.getStructFields(ty)) |f| { + if (f.name == field_id) return null; + } + // Generic instance: genericInstanceMethod is keyed by the instance name + // (e.g. "List(i64)"), which is what formatTypeName produces. + const tn = self.formatTypeName(ty); + if (self.genericInstanceMethod(tn, field)) |m| { + return if (m.fd.is_get) m.fd else null; + } + // Plain struct: methods are registered "StructName.method" in fn_ast_map. + const info = self.module.types.get(ty); + if (info == .@"struct") { + const sname = self.module.types.getString(info.@"struct".name); + const q = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ sname, field }) catch return null; + if (self.program_index.fn_ast_map.get(q)) |fd| { + return if (fd.is_get) fd else null; + } + } + return null; +} + pub fn lowerFieldAccessOnType(self: *Lowering, obj: Ref, obj_ty: TypeId, field: []const u8, span: ast.Span) Ref { const field_name_id = self.module.types.internString(field); diff --git a/src/ir/lower/generic.zig b/src/ir/lower/generic.zig index f86e53f1..8dc5bc02 100644 --- a/src/ir/lower/generic.zig +++ b/src/ir/lower/generic.zig @@ -116,6 +116,7 @@ pub fn monomorphizeFunction(self: *Lowering, fd: *const ast.FnDecl, mangled_name _ = func_id; self.builder.currentFunc().has_implicit_ctx = wants_ctx; self.builder.currentFunc().is_naked = (fd.abi == .naked); + self.builder.currentFunc().is_get = fd.is_get; // Create entry block const entry_name = self.module.types.internString("entry"); diff --git a/src/ir/lower/pack.zig b/src/ir/lower/pack.zig index 9c573a4f..1f3749b8 100644 --- a/src/ir/lower/pack.zig +++ b/src/ir/lower/pack.zig @@ -950,6 +950,7 @@ pub fn monomorphizePackFn( _ = self.builder.beginFunction(name_id, params.items, ret_ty); self.builder.currentFunc().has_implicit_ctx = wants_ctx; self.builder.currentFunc().is_naked = (fd.abi == .naked); + self.builder.currentFunc().is_get = fd.is_get; const entry_name = self.module.types.internString("entry"); const entry = self.builder.appendBlock(entry_name, &.{}); diff --git a/src/lexer.zig b/src/lexer.zig index 31057439..c24e61b7 100644 --- a/src/lexer.zig +++ b/src/lexer.zig @@ -112,6 +112,7 @@ pub const Lexer = struct { .{ "#jni_main", Tag.hash_jni_main }, .{ "#selector", Tag.hash_selector }, .{ "#property", Tag.hash_property }, + .{ "#get", Tag.hash_get }, .{ "#caller_location", Tag.hash_caller_location }, }; inline for (directives) |d| { diff --git a/src/lsp/server.zig b/src/lsp/server.zig index f85ed695..cc15d7ae 100644 --- a/src/lsp/server.zig +++ b/src/lsp/server.zig @@ -1715,6 +1715,7 @@ pub const Server = struct { .hash_jni_main, .hash_selector, .hash_property, + .hash_get, .hash_caller_location, => ST.keyword, diff --git a/src/parser.zig b/src/parser.zig index 3b27d812..0ae3efd5 100644 --- a/src/parser.zig +++ b/src/parser.zig @@ -1958,6 +1958,14 @@ pub const Parser = struct { return_type = try self.parseTypeExpr(); } + // Optional `#get` property-accessor marker: `name :: (self) -> R #get => expr;`. + // The method is invoked via field syntax (`obj.name`) rather than `obj.name()`. + var is_get = false; + if (self.current.tag == .hash_get) { + is_get = true; + self.advance(); + } + // Optional ABI / calling-convention annotation: `abi(.c)` / `abi(.zig)` / // `abi(.naked)`. Sits in the postfix slot BEFORE the `extern`/`export` // linkage keyword (it is part of the function declaration). `abi(.zig)` @@ -2048,6 +2056,7 @@ pub const Parser = struct { .extern_name = extern_name, .name_span = name_span, .is_raw = name_is_raw, + .is_get = is_get, } }); } @@ -3793,6 +3802,7 @@ pub const Parser = struct { if (self.current.tag == .fat_arrow) return true; if (self.current.tag == .l_brace) return true; if (self.current.tag == .hash_builtin) return true; + if (self.current.tag == .hash_get) return true; // `-> R #get => …` is a fn def if (self.current.tag == .kw_abi) return true; // Postfix linkage modifier after the return type: `-> R extern;` / // `-> R export { … }` (and `-> R abi(.c) extern`). Marks a fn def. diff --git a/src/token.zig b/src/token.zig index 83777ffb..119e6501 100644 --- a/src/token.zig +++ b/src/token.zig @@ -141,6 +141,7 @@ pub const Tag = enum { hash_jni_method_descriptor, // `#jni_method_descriptor("(Sig)Ret")` per-method JNI descriptor override hash_selector, // `#selector("explicit:string")` per-method Obj-C selector override (Phase 3.2) hash_property, // `#property[(modifier, ...)]` field directive — synthesizes getter/setter dispatch (M2.2) + hash_get, // `name :: (self) -> R #get => expr;` — a no-paren property accessor method (read via field syntax) hash_caller_location, // `#caller_location` — as a param default, synthesizes the call site's Source_Location (ERR E4.1b) hash_jni_env, // `#jni_env(env) { body }` block-form env-scoping intrinsic hash_jni_main, // `#jni_main #jni_class(...) { ... }` — class is the launchable Android Activity