feat(py_lsp): instance attribute typing — 79% -> 95% resolution

Round 4 / final parity push. Tracks self.x = expr from __init__ and
class-body annotated assignments (`x: Foo`) as registered fields on
the enclosing class type.

py_register_instance_field mutates the registry's CBMRegisteredType
field_names / field_types arrays in place. Re-registering the same
name overwrites; first-seen wins for capture order. Arena-allocated,
not a hot path.

py_lookup_field walks the alias / embedded chain with the same
CBM_LSP_MAX_LOOKUP_DEPTH cap as method lookup, so inherited fields
resolve through MRO without stack-overflow risk on pathological
hierarchies.

Field-fallback wired into py_eval_expr_type's attribute branch:
when method lookup misses, try field lookup. Returns the field's
type so downstream attribute / call resolution chains naturally.
Same fallback applied to UNION (single-match heuristic).

py_process_class now does three passes:
1. Class-body annotated assignments (`x: int = ...`) → fields.
2. __init__ / __post_init__ → self.x = expr fields.
3. All other methods.
This guarantees fields are registered before methods that reference
them, regardless of source order.

Annotated assignment binding fix: py_resolve_annotation is now called
on assignment annotations too, so `self.cache: dict[str, str] = {}`
binds with the proper TEMPLATE type instead of NAMED("dict[str, str]").

Bench result on the 136-line fixture: **95%** (36/38 calls resolved,
8.06 ms). Up from 79%. The 5% gap covers cases that need a full
constraint solver — see Final Stopping Point doc.

2 new tests; all 2890 prior tests stay green. Total 2892 / 0.
This commit is contained in:
Martin Vogel committed 2026-05-09 03:38:06 +02:00
1 parent e7dcadc3dd
commit 4b993899a0
2 files changed
+244 -11

No files matched your search

+186 -11
View File
@@ -28,6 +28,10 @@ static void py_emit_resolved_call(PyLSPContext* ctx, const char* callee_qn,
const char* strategy, float confidence);
static const CBMType* py_resolve_annotation(PyLSPContext* ctx, const char* ann);
static const CBMType* py_iterable_element_type(PyLSPContext* ctx, const CBMType* iter_type);
static const CBMType* py_lookup_field(PyLSPContext* ctx, const char* type_qn,
const char* field_name);
static void py_register_instance_field(PyLSPContext* ctx, const char* class_qn,
const char* field_name, const CBMType* field_type);
void py_lsp_init(PyLSPContext* ctx, CBMArena* arena, const char* source, int source_len,
const CBMTypeRegistry* registry, const char* module_qn, CBMResolvedCallArray* out) {
@@ -212,6 +216,94 @@ static const CBMRegisteredFunc* py_lookup_attribute(PyLSPContext* ctx,
return py_lookup_attribute_depth(ctx, type_qn, member_name, 0);
}
/* Look up a field on a registered type. Walks alias / embedded chain
* with the same depth cap as method lookup. Returns the field's type
* or NULL if no match. */
static const CBMType* py_lookup_field_depth(PyLSPContext* ctx, const char* type_qn,
const char* field_name, int depth) {
if (!ctx || !ctx->registry || !type_qn || !field_name) return NULL;
if (depth > CBM_LSP_MAX_LOOKUP_DEPTH) return NULL;
const CBMRegisteredType* rt = cbm_registry_lookup_type(ctx->registry, type_qn);
if (!rt) return NULL;
if (rt->field_names && rt->field_types) {
for (int i = 0; rt->field_names[i]; i++) {
if (strcmp(rt->field_names[i], field_name) == 0) {
return rt->field_types[i];
}
}
}
if (rt->alias_of) {
const CBMType* a = py_lookup_field_depth(ctx, rt->alias_of, field_name, depth + 1);
if (a) return a;
}
if (rt->embedded_types) {
for (int i = 0; rt->embedded_types[i]; i++) {
const CBMType* e = py_lookup_field_depth(ctx,
rt->embedded_types[i], field_name, depth + 1);
if (e) return e;
}
}
return NULL;
}
static const CBMType* py_lookup_field(PyLSPContext* ctx, const char* type_qn,
const char* field_name) {
return py_lookup_field_depth(ctx, type_qn, field_name, 0);
}
/* Append a (name, type) entry to a registered class's field arrays.
* Idempotent — re-registering the same name overwrites the existing
* entry. Mutates the array in place by allocating a new (slightly
* larger) array on each call; not a hot path so the cost is acceptable. */
static void py_register_instance_field(PyLSPContext* ctx, const char* class_qn,
const char* field_name, const CBMType* field_type) {
if (!ctx || !ctx->registry || !class_qn || !field_name || !field_type) return;
// Find the type entry. cbm_registry_lookup_type returns a const pointer;
// we need a mutable pointer into the registry's array.
CBMRegisteredType* rt = NULL;
for (int i = 0; i < ctx->registry->type_count; i++) {
const char* qn = ctx->registry->types[i].qualified_name;
if (qn && strcmp(qn, class_qn) == 0) {
rt = &ctx->registry->types[i];
break;
}
}
if (!rt) return;
// Overwrite if name already registered.
if (rt->field_names && rt->field_types) {
for (int i = 0; rt->field_names[i]; i++) {
if (strcmp(rt->field_names[i], field_name) == 0) {
((const CBMType**)rt->field_types)[i] = field_type;
return;
}
}
}
int existing = 0;
if (rt->field_names) {
while (rt->field_names[existing]) existing++;
}
int new_count = existing + 1;
const char** new_names = (const char**)cbm_arena_alloc(ctx->arena,
(size_t)(new_count + 1) * sizeof(const char*));
const CBMType** new_types = (const CBMType**)cbm_arena_alloc(ctx->arena,
(size_t)(new_count + 1) * sizeof(const CBMType*));
if (!new_names || !new_types) return;
for (int i = 0; i < existing; i++) {
new_names[i] = rt->field_names[i];
new_types[i] = rt->field_types[i];
}
new_names[existing] = cbm_arena_strdup(ctx->arena, field_name);
new_types[existing] = field_type;
new_names[new_count] = NULL;
new_types[new_count] = NULL;
rt->field_names = new_names;
rt->field_types = new_types;
}
/* ── expression typing ────────────────────────────────────────── */
/* Map a tree-sitter Python literal node to a BUILTIN type. Returns NULL if
@@ -387,6 +479,10 @@ static const CBMType* py_eval_expr_type(PyLSPContext* ctx, TSNode node) {
obj_type->data.named.qualified_name, attr_name);
if (f) return py_func_return_type_recv(ctx, f->qualified_name,
obj_type->data.named.qualified_name);
// Field fallback: instance attributes from __init__'s self.x = expr.
const CBMType* field = py_lookup_field(ctx,
obj_type->data.named.qualified_name, attr_name);
if (field) return field;
}
if (obj_type->kind == CBM_TYPE_UNION) {
int matches = 0;
@@ -406,6 +502,17 @@ static const CBMType* py_eval_expr_type(PyLSPContext* ctx, TSNode node) {
if (matches == 1 && hit) {
return py_func_return_type_recv(ctx, hit->qualified_name, hit_recv);
}
// Field fallback for UNION: same single-match heuristic.
int field_matches = 0;
const CBMType* field_hit = NULL;
for (int i = 0; i < obj_type->data.union_type.count; i++) {
const CBMType* m = obj_type->data.union_type.members[i];
if (!m || m->kind != CBM_TYPE_NAMED) continue;
const CBMType* fld = py_lookup_field(ctx,
m->data.named.qualified_name, attr_name);
if (fld) { field_matches++; field_hit = fld; }
}
if (field_matches == 1 && field_hit) return field_hit;
}
return cbm_type_unknown();
}
@@ -551,14 +658,11 @@ static void py_process_statement(PyLSPContext* ctx, TSNode node) {
: py_eval_expr_type(ctx, right);
// Annotated assignment: x: T = expr — annotation wins.
if (!ts_node_is_null(ann)) {
bool has_annotation = !ts_node_is_null(ann);
if (has_annotation) {
char* ann_text = py_node_text(ctx, ann);
if (ann_text && ann_text[0]) {
const CBMType* ann_t = cbm_scope_lookup(ctx->current_scope, ann_text);
if (cbm_type_is_unknown(ann_t)) {
ann_t = cbm_type_named(ctx->arena, ann_text);
}
rhs_type = ann_t;
rhs_type = py_resolve_annotation(ctx, ann_text);
}
}
@@ -568,8 +672,22 @@ static void py_process_statement(PyLSPContext* ctx, TSNode node) {
char* name = py_node_text(ctx, left);
if (name) cbm_scope_bind(ctx->current_scope, name, rhs_type);
} else if (strcmp(lk, "attribute") == 0) {
// self.x = expr — record on the enclosing class via a synthetic
// type entry. For now we just don't bind locally.
// self.x = expr — record as instance field on the enclosing
// class. Effective inside any method that resolves obj.x where
// obj is an instance of the class.
TSNode obj = ts_node_child_by_field_name(left, "object", 6);
TSNode attr = ts_node_child_by_field_name(left, "attribute", 9);
if (!ts_node_is_null(obj) && !ts_node_is_null(attr) &&
strcmp(ts_node_type(obj), "identifier") == 0 &&
ctx->enclosing_class_qn) {
char* obj_name = py_node_text(ctx, obj);
char* attr_name = py_node_text(ctx, attr);
if (obj_name && attr_name &&
(strcmp(obj_name, "self") == 0 || strcmp(obj_name, "cls") == 0)) {
py_register_instance_field(ctx, ctx->enclosing_class_qn,
attr_name, rhs_type);
}
}
}
return;
}
@@ -1484,6 +1602,14 @@ static void py_process_function(PyLSPContext* ctx, TSNode func_node, const char*
ctx->enclosing_func_qn = prev_func;
}
/* Return true iff func_node's name is __init__ or __post_init__. */
static bool py_is_init_method(PyLSPContext* ctx, TSNode func_node) {
TSNode name = ts_node_child_by_field_name(func_node, "name", 4);
if (ts_node_is_null(name)) return false;
char* nm = py_node_text(ctx, name);
return nm && (strcmp(nm, "__init__") == 0 || strcmp(nm, "__post_init__") == 0);
}
static void py_process_class(PyLSPContext* ctx, TSNode class_node) {
TSNode name_node = ts_node_child_by_field_name(class_node, "name", 4);
if (ts_node_is_null(name_node)) return;
@@ -1496,19 +1622,68 @@ static void py_process_class(PyLSPContext* ctx, TSNode class_node) {
TSNode body = ts_node_child_by_field_name(class_node, "body", 4);
if (!ts_node_is_null(body)) {
uint32_t bnc = ts_node_named_child_count(body);
// First pass: process class-level annotated assignments (PEP 526
// class-body field annotations like `x: int`) and dunder __init__
// methods so fields are registered before sibling methods that
// reference them.
for (uint32_t i = 0; i < bnc; i++) {
TSNode c = ts_node_named_child(body, i);
const char* ck = ts_node_type(c);
if (strcmp(ck, "expression_statement") == 0 &&
ts_node_named_child_count(c) > 0) {
TSNode inner = ts_node_named_child(c, 0);
const char* ik = ts_node_type(inner);
// class C: x: int = 1 → bind x as field
if (strcmp(ik, "assignment") == 0) {
TSNode left = ts_node_child_by_field_name(inner, "left", 4);
TSNode ann = ts_node_child_by_field_name(inner, "type", 4);
if (!ts_node_is_null(left) && !ts_node_is_null(ann) &&
strcmp(ts_node_type(left), "identifier") == 0) {
char* fname = py_node_text(ctx, left);
char* atext = py_node_text(ctx, ann);
if (fname && atext) {
const CBMType* ft = py_resolve_annotation(ctx, atext);
py_register_instance_field(ctx,
ctx->enclosing_class_qn, fname, ft);
}
}
}
}
}
// Second pass: __init__ (so self.x assignments populate fields).
for (uint32_t i = 0; i < bnc; i++) {
TSNode c = ts_node_named_child(body, i);
const char* ck = ts_node_type(c);
TSNode fn_node = c;
bool is_decorated = strcmp(ck, "decorated_definition") == 0;
if (is_decorated) {
fn_node = ts_node_child_by_field_name(c, "definition", 10);
if (ts_node_is_null(fn_node) ||
strcmp(ts_node_type(fn_node), "function_definition") != 0) continue;
} else if (strcmp(ck, "function_definition") != 0) {
continue;
}
if (py_is_init_method(ctx, fn_node)) {
py_process_function(ctx, fn_node, ctx->enclosing_class_qn);
}
}
// Third pass: every other method (and nested classes).
for (uint32_t i = 0; i < bnc; i++) {
TSNode c = ts_node_named_child(body, i);
const char* ck = ts_node_type(c);
if (strcmp(ck, "function_definition") == 0) {
py_process_function(ctx, c, ctx->enclosing_class_qn);
if (!py_is_init_method(ctx, c)) {
py_process_function(ctx, c, ctx->enclosing_class_qn);
}
} else if (strcmp(ck, "decorated_definition") == 0) {
TSNode def = ts_node_child_by_field_name(c, "definition", 10);
if (!ts_node_is_null(def) &&
strcmp(ts_node_type(def), "function_definition") == 0) {
strcmp(ts_node_type(def), "function_definition") == 0 &&
!py_is_init_method(ctx, def)) {
py_process_function(ctx, def, ctx->enclosing_class_qn);
}
} else if (strcmp(ck, "class_definition") == 0) {
py_process_class(ctx, c); // nested class
py_process_class(ctx, c);
}
}
}
+58
View File
@@ -1038,6 +1038,61 @@ TEST(pylsp_round3_async_await_pass_through) {
PASS();
}
/* ── Round 4 — instance attribute typing ──────────────────────── */
TEST(pylsp_round4_instance_attribute_init) {
/* class C:
* def __init__(self, cfg: Config):
* self.cfg = cfg # field cfg : Config
* def use(self):
* return self.cfg.display() # resolves through field type */
CBMFileResult *r = extract_py(
"class Config:\n"
" def display(self):\n"
" return 1\n"
"class App:\n"
" def __init__(self, cfg: Config):\n"
" self.cfg = cfg\n"
" def use(self):\n"
" self.cfg.display()\n");
/* Note: extra indentation simulates a body block; real test
* mirrors realistic Python code. */
ASSERT_NOT_NULL(r);
/* The above source has bad indent — replace with proper test source. */
cbm_free_result(r);
r = extract_py(
"class Config:\n"
" def display(self):\n"
" return 1\n"
"class App:\n"
" def __init__(self, cfg: Config):\n"
" self.cfg = cfg\n"
" def use(self):\n"
" return self.cfg.display()\n");
ASSERT_NOT_NULL(r);
ASSERT_GTE(require_resolved(r, "use", "display"), 0);
cbm_free_result(r);
PASS();
}
TEST(pylsp_round4_instance_attribute_class_annotation) {
/* class C:
* x: Foo
* def use(self): return self.x.method() */
CBMFileResult *r = extract_py(
"class Foo:\n"
" def method(self):\n"
" return 1\n"
"class C:\n"
" x: Foo\n"
" def use(self):\n"
" return self.x.method()\n");
ASSERT_NOT_NULL(r);
ASSERT_GTE(require_resolved(r, "use", "method"), 0);
cbm_free_result(r);
PASS();
}
/* ── Suite ─────────────────────────────────────────────────────── */
SUITE(py_lsp) {
@@ -1101,4 +1156,7 @@ SUITE(py_lsp) {
/* Round 3 — match/case + async */
RUN_TEST(pylsp_round3_match_case_class_pattern);
RUN_TEST(pylsp_round3_async_await_pass_through);
/* Round 4 — instance attribute typing */
RUN_TEST(pylsp_round4_instance_attribute_init);
RUN_TEST(pylsp_round4_instance_attribute_class_annotation);
}