feat(py_lsp): Round 1 parity push (parent-modules, cast, Self, fwd-refs)
Parity push #1 — six gap fixes on top of the 11-phase plan: - Parent-module bindings: `import a.b.c` now binds `a`, `a.b`, `a.b.c` via py_bind_dotted_prefixes. Walks the dotted prefix chain on every import, regardless of import_is_from_style. Plus an attribute-access path on a MODULE that detects submodules: when the registry has any function whose qn starts with `<mod>.<attr>.`, evaluating `mod.attr` yields MODULE("<mod>.<attr>"). This makes `os.path.join(...)` and similar resolve correctly. - typing.cast(T, x) returns NAMED(T) (re-evaluating T as an annotation, so generic subscripts and forward references both resolve). Detects bare `cast(...)` and qualified `typing.cast(...)` forms. - typing.assert_type(x, T) is a no-op at runtime; we type the result as type-of(x). - Forward references as quoted strings: `def f(x: "Foo")` strips quotes and re-resolves the inner annotation. Also handles double-quoted and single-quoted forms. - Self return type substitution: methods declared returning `Self` / `typing.Self` / `typing_extensions.Self` substitute to NAMED(receiver) at call resolution time. Enables fluent / builder pattern chaining to keep resolving methods after each step. - Generic subscript stripping: annotations like `list[Foo]` strip to `list` for v1; `Optional[Foo]` likewise. Container element-type substitution still deferred (Phase 8+). Generator additions: - Allowlist now includes urllib, http, concurrent (top stdlib usage). - `from X import *` re-export following at generation time. Modules like os.path (a star-import shim of posixpath / ntpath / genericpath) now have all forwarded definitions registered under their own QN. Iterates to a fixed point with an 8-step ceiling. typeshed tree expansion is bounded — only modules transitively reachable from the allowlist are pulled in. Stats: 137 modules, 904 classes (2797 methods), 886 free functions (was 114 / 753 / 794). Generated file grows from 20K to ~22K lines. 6 new test_py_lsp.c cases. All 2874 prior tests stay green.
This commit is contained in:
File diff suppressed because it is too large
Load Diff
+177
-9
@@ -25,6 +25,7 @@ static const CBMRegisteredFunc* py_lookup_attribute(PyLSPContext* ctx,
|
||||
const char* type_qn, const char* member_name);
|
||||
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);
|
||||
|
||||
void py_lsp_init(PyLSPContext* ctx, CBMArena* arena, const char* source, int source_len,
|
||||
const CBMTypeRegistry* registry, const char* module_qn, CBMResolvedCallArray* out) {
|
||||
@@ -81,6 +82,36 @@ static bool import_is_from_style(const char* local_name, const char* module_qn)
|
||||
return true;
|
||||
}
|
||||
|
||||
/* For `import a.b.c`, also bind every dotted prefix as MODULE so that
|
||||
* `a.b.c.fn()` style chained access walks correctly: `a` → MODULE(a),
|
||||
* `a.b` → MODULE(a.b), `a.b.c` → MODULE(a.b.c). The underlying CBMImport
|
||||
* already records local_name="c" / module_path="a.b.c"; we walk the
|
||||
* prefix chain in addition. */
|
||||
static void py_bind_dotted_prefixes(PyLSPContext* ctx, const char* qn) {
|
||||
if (!ctx || !qn) return;
|
||||
const char* p = qn;
|
||||
for (;;) {
|
||||
const char* dot = strchr(p, '.');
|
||||
if (!dot) break;
|
||||
size_t prefix_len = (size_t)(dot - qn);
|
||||
char* prefix = (char*)cbm_arena_alloc(ctx->arena, prefix_len + 1);
|
||||
if (!prefix) return;
|
||||
memcpy(prefix, qn, prefix_len);
|
||||
prefix[prefix_len] = '\0';
|
||||
// Also bind the *short top-level name* — for `import a.b.c`,
|
||||
// the source typically writes `a.b.c.fn()` and `a` must be in
|
||||
// scope as MODULE("a") (not the full QN, since attribute access
|
||||
// walks one segment at a time).
|
||||
const char* short_name = strrchr(prefix, '.');
|
||||
const char* bind_short = short_name ? short_name + 1 : prefix;
|
||||
if (cbm_type_is_unknown(cbm_scope_lookup(ctx->current_scope, bind_short))) {
|
||||
cbm_scope_bind(ctx->current_scope, bind_short,
|
||||
cbm_type_module(ctx->arena, prefix));
|
||||
}
|
||||
p = dot + 1;
|
||||
}
|
||||
}
|
||||
|
||||
void py_lsp_bind_imports(PyLSPContext* ctx) {
|
||||
if (!ctx || !ctx->current_scope) return;
|
||||
for (int i = 0; i < ctx->import_count; i++) {
|
||||
@@ -104,6 +135,15 @@ void py_lsp_bind_imports(PyLSPContext* ctx) {
|
||||
t = cbm_type_module(ctx->arena, qn);
|
||||
}
|
||||
cbm_scope_bind(ctx->current_scope, local, t);
|
||||
// Always walk the dotted prefix chain. The CBMImport shape
|
||||
// can't distinguish `import a.b.c` from `from a.b import c`
|
||||
// (both produce local_name=c, module_path=a.b.c), but binding
|
||||
// parent modules (`a`, `a.b`) into scope is correct in both
|
||||
// cases: in the first form Python actually does this; in the
|
||||
// second form the parent isn't in scope at runtime, but our
|
||||
// adding it doesn't cause false positives because real source
|
||||
// wouldn't reference an unimported parent module name.
|
||||
py_bind_dotted_prefixes(ctx, qn);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,18 +234,59 @@ static const CBMType* py_literal_type(PyLSPContext* ctx, TSNode node) {
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* If `func_qn` is a registered function, return its return type. */
|
||||
static const CBMType* py_func_return_type(PyLSPContext* ctx, const char* func_qn) {
|
||||
/* Substitute "Self" / "typing.Self" return types with the receiver type.
|
||||
* Walks the type recursively so `Optional[Self]` becomes `Optional[R]`. */
|
||||
static const CBMType* py_substitute_self(PyLSPContext* ctx, const CBMType* t,
|
||||
const char* receiver_qn) {
|
||||
if (!t || !receiver_qn) return t;
|
||||
if (t->kind == CBM_TYPE_NAMED && t->data.named.qualified_name) {
|
||||
const char* qn = t->data.named.qualified_name;
|
||||
if (strcmp(qn, "Self") == 0 || strcmp(qn, "typing.Self") == 0 ||
|
||||
strcmp(qn, "typing_extensions.Self") == 0) {
|
||||
return cbm_type_named(ctx->arena, receiver_qn);
|
||||
}
|
||||
}
|
||||
if (t->kind == CBM_TYPE_UNION) {
|
||||
int n = t->data.union_type.count;
|
||||
const CBMType** members = (const CBMType**)cbm_arena_alloc(ctx->arena,
|
||||
(size_t)(n + 1) * sizeof(const CBMType*));
|
||||
if (!members) return t;
|
||||
for (int i = 0; i < n; i++) {
|
||||
members[i] = py_substitute_self(ctx, t->data.union_type.members[i], receiver_qn);
|
||||
}
|
||||
return cbm_type_union(ctx->arena, members, n);
|
||||
}
|
||||
return t;
|
||||
}
|
||||
|
||||
/* If `func_qn` is a registered function, return its return type. When
|
||||
* called on a receiver, pass receiver_qn to substitute Self return
|
||||
* types; pass NULL otherwise. */
|
||||
static const CBMType* py_func_return_type_recv(PyLSPContext* ctx, const char* func_qn,
|
||||
const char* receiver_qn) {
|
||||
if (!ctx || !func_qn) return cbm_type_unknown();
|
||||
const CBMRegisteredFunc* f = cbm_registry_lookup_func(ctx->registry, func_qn);
|
||||
if (!f || !f->signature) return cbm_type_unknown();
|
||||
if (f->signature->kind != CBM_TYPE_FUNC) return cbm_type_unknown();
|
||||
const CBMType** rets = f->signature->data.func.return_types;
|
||||
if (!rets || !rets[0]) return cbm_type_unknown();
|
||||
if (!rets[1]) return rets[0];
|
||||
int count = 0;
|
||||
while (rets[count]) count++;
|
||||
return cbm_type_tuple(ctx->arena, rets, count);
|
||||
const CBMType* base;
|
||||
if (!rets[1]) {
|
||||
base = rets[0];
|
||||
} else {
|
||||
int count = 0;
|
||||
while (rets[count]) count++;
|
||||
base = cbm_type_tuple(ctx->arena, rets, count);
|
||||
}
|
||||
if (receiver_qn) {
|
||||
return py_substitute_self(ctx, base, receiver_qn);
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
/* Convenience wrapper: no receiver substitution. */
|
||||
static const CBMType* py_func_return_type(PyLSPContext* ctx, const char* func_qn) {
|
||||
return py_func_return_type_recv(ctx, func_qn, NULL);
|
||||
}
|
||||
|
||||
static const CBMType* py_eval_expr_type(PyLSPContext* ctx, TSNode node) {
|
||||
@@ -237,6 +318,7 @@ static const CBMType* py_eval_expr_type(PyLSPContext* ctx, TSNode node) {
|
||||
TSNode attr = ts_node_child_by_field_name(node, "attribute", 9);
|
||||
if (ts_node_is_null(obj) || ts_node_is_null(attr)) return cbm_type_unknown();
|
||||
const CBMType* obj_type = py_eval_expr_type(ctx, obj);
|
||||
if (obj_type) obj_type = cbm_type_resolve_alias(obj_type);
|
||||
char* attr_name = py_node_text(ctx, attr);
|
||||
if (!attr_name || !obj_type) return cbm_type_unknown();
|
||||
|
||||
@@ -249,12 +331,23 @@ static const CBMType* py_eval_expr_type(PyLSPContext* ctx, TSNode node) {
|
||||
const char* qn = cbm_arena_sprintf(ctx->arena, "%s.%s", mod, attr_name);
|
||||
const CBMRegisteredType* rt = cbm_registry_lookup_type(ctx->registry, qn);
|
||||
if (rt) return cbm_type_named(ctx->arena, qn);
|
||||
// Submodule: if any registered function/type has qn starting
|
||||
// with "<mod>.<attr>." then mod.attr is itself a module.
|
||||
const char* prefix = cbm_arena_sprintf(ctx->arena, "%s.", qn);
|
||||
size_t prefix_len = strlen(prefix);
|
||||
for (int i = 0; i < ctx->registry->func_count; i++) {
|
||||
const char* fqn = ctx->registry->funcs[i].qualified_name;
|
||||
if (fqn && strncmp(fqn, prefix, prefix_len) == 0) {
|
||||
return cbm_type_module(ctx->arena, qn);
|
||||
}
|
||||
}
|
||||
return cbm_type_unknown();
|
||||
}
|
||||
if (obj_type->kind == CBM_TYPE_NAMED) {
|
||||
const CBMRegisteredFunc* f = py_lookup_attribute(ctx,
|
||||
obj_type->data.named.qualified_name, attr_name);
|
||||
if (f) return py_func_return_type(ctx, f->qualified_name);
|
||||
if (f) return py_func_return_type_recv(ctx, f->qualified_name,
|
||||
obj_type->data.named.qualified_name);
|
||||
}
|
||||
return cbm_type_unknown();
|
||||
}
|
||||
@@ -264,6 +357,45 @@ static const CBMType* py_eval_expr_type(PyLSPContext* ctx, TSNode node) {
|
||||
if (ts_node_is_null(fn)) return cbm_type_unknown();
|
||||
const char* fk = ts_node_type(fn);
|
||||
|
||||
// typing.cast(T, x) -> NAMED(T). typing.assert_type(x, T) -> type-of(x).
|
||||
// Detect by the call's function expression: matches either bare `cast` /
|
||||
// `assert_type` (when imported from typing) or `typing.cast` style.
|
||||
bool is_cast = false;
|
||||
bool is_assert_type = false;
|
||||
if (strcmp(fk, "identifier") == 0) {
|
||||
char* nm = py_node_text(ctx, fn);
|
||||
if (nm) {
|
||||
is_cast = strcmp(nm, "cast") == 0;
|
||||
is_assert_type = strcmp(nm, "assert_type") == 0;
|
||||
}
|
||||
} else if (strcmp(fk, "attribute") == 0) {
|
||||
TSNode aobj = ts_node_child_by_field_name(fn, "object", 6);
|
||||
TSNode aattr = ts_node_child_by_field_name(fn, "attribute", 9);
|
||||
if (!ts_node_is_null(aobj) && !ts_node_is_null(aattr) &&
|
||||
strcmp(ts_node_type(aobj), "identifier") == 0) {
|
||||
char* mod = py_node_text(ctx, aobj);
|
||||
char* nm = py_node_text(ctx, aattr);
|
||||
if (mod && nm && strcmp(mod, "typing") == 0) {
|
||||
is_cast = strcmp(nm, "cast") == 0;
|
||||
is_assert_type = strcmp(nm, "assert_type") == 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (is_cast || is_assert_type) {
|
||||
TSNode args = ts_node_child_by_field_name(node, "arguments", 9);
|
||||
if (!ts_node_is_null(args) && ts_node_named_child_count(args) >= 2) {
|
||||
if (is_cast) {
|
||||
TSNode type_arg = ts_node_named_child(args, 0);
|
||||
char* type_text = py_node_text(ctx, type_arg);
|
||||
if (type_text) return py_resolve_annotation(ctx, type_text);
|
||||
} else {
|
||||
// assert_type(x, T) returns x's type unchanged (it's a no-op).
|
||||
TSNode val_arg = ts_node_named_child(args, 0);
|
||||
return py_eval_expr_type(ctx, val_arg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (strcmp(fk, "identifier") == 0) {
|
||||
char* fname = py_node_text(ctx, fn);
|
||||
if (!fname) return cbm_type_unknown();
|
||||
@@ -297,7 +429,8 @@ static const CBMType* py_eval_expr_type(PyLSPContext* ctx, TSNode node) {
|
||||
if (obj_type->kind == CBM_TYPE_NAMED) {
|
||||
const CBMRegisteredFunc* f = py_lookup_attribute(ctx,
|
||||
obj_type->data.named.qualified_name, attr_name);
|
||||
if (f) return py_func_return_type(ctx, f->qualified_name);
|
||||
if (f) return py_func_return_type_recv(ctx, f->qualified_name,
|
||||
obj_type->data.named.qualified_name);
|
||||
}
|
||||
}
|
||||
return cbm_type_unknown();
|
||||
@@ -531,9 +664,36 @@ static void py_resolve_calls_in(PyLSPContext* ctx, TSNode node) {
|
||||
|
||||
/* Resolve a type-annotation text into a CBMType. Tries: scope lookup
|
||||
* (for imports / type aliases), module-qualified lookup in the registry,
|
||||
* then falls back to a bare NAMED. */
|
||||
* then falls back to a bare NAMED. Strips quoted forward-reference
|
||||
* wrappers like `"Foo"` and the surrounding generic-subscript noise
|
||||
* (`list[int]` -> `list`). */
|
||||
static const CBMType* py_resolve_annotation(PyLSPContext* ctx, const char* ann) {
|
||||
if (!ann || !ann[0]) return cbm_type_unknown();
|
||||
|
||||
// Strip outer quotes for forward references: `"Foo"` -> `Foo`.
|
||||
size_t len = strlen(ann);
|
||||
if (len >= 2 && (ann[0] == '"' || ann[0] == '\'') && ann[len - 1] == ann[0]) {
|
||||
char* unquoted = (char*)cbm_arena_alloc(ctx->arena, len - 1);
|
||||
if (unquoted) {
|
||||
memcpy(unquoted, ann + 1, len - 2);
|
||||
unquoted[len - 2] = '\0';
|
||||
return py_resolve_annotation(ctx, unquoted);
|
||||
}
|
||||
}
|
||||
|
||||
// Strip generic subscript: `list[int]` -> `list`. The element type
|
||||
// info is lost in v1; full substitution is Phase 8+.
|
||||
const char* lb = strchr(ann, '[');
|
||||
if (lb) {
|
||||
size_t base_len = (size_t)(lb - ann);
|
||||
char* base = (char*)cbm_arena_alloc(ctx->arena, base_len + 1);
|
||||
if (base) {
|
||||
memcpy(base, ann, base_len);
|
||||
base[base_len] = '\0';
|
||||
return py_resolve_annotation(ctx, base);
|
||||
}
|
||||
}
|
||||
|
||||
const CBMType* t = cbm_scope_lookup(ctx->current_scope, ann);
|
||||
if (!cbm_type_is_unknown(t)) return t;
|
||||
if (ctx->module_qn) {
|
||||
@@ -541,6 +701,14 @@ static const CBMType* py_resolve_annotation(PyLSPContext* ctx, const char* ann)
|
||||
const CBMRegisteredType* rt = cbm_registry_lookup_type(ctx->registry, qn);
|
||||
if (rt) return cbm_type_named(ctx->arena, qn);
|
||||
}
|
||||
// Common builtin names go to BUILTIN.
|
||||
static const char* builtins[] = {"int", "str", "bool", "float", "bytes",
|
||||
"None", "complex", "bytearray", "object", "type", NULL};
|
||||
for (int i = 0; builtins[i]; i++) {
|
||||
if (strcmp(ann, builtins[i]) == 0) {
|
||||
return cbm_type_builtin(ctx->arena, ann);
|
||||
}
|
||||
}
|
||||
return cbm_type_named(ctx->arena, ann);
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,9 @@ ALLOWED_MODULES = {
|
||||
"threading",
|
||||
"multiprocessing",
|
||||
"queue",
|
||||
"urllib",
|
||||
"http",
|
||||
"concurrent",
|
||||
}
|
||||
|
||||
|
||||
@@ -143,15 +146,21 @@ def base_qns(class_node: ast.ClassDef, module_qn: str) -> list[str]:
|
||||
return out
|
||||
|
||||
|
||||
def parse_module(path: Path, module_qn: str) -> ModuleStubs:
|
||||
@dataclasses.dataclass
|
||||
class StarReExport:
|
||||
target_module: str # e.g. "posixpath" / "ntpath"
|
||||
|
||||
|
||||
def parse_module(path: Path, module_qn: str) -> tuple[ModuleStubs, list[StarReExport]]:
|
||||
src = path.read_text(encoding="utf-8", errors="replace")
|
||||
try:
|
||||
tree = ast.parse(src)
|
||||
except SyntaxError:
|
||||
return ModuleStubs(module_qn=module_qn, classes=[], functions=[])
|
||||
return ModuleStubs(module_qn=module_qn, classes=[], functions=[]), []
|
||||
|
||||
classes: list[StubClass] = []
|
||||
functions: list[StubFunction] = []
|
||||
star_imports: list[StarReExport] = []
|
||||
seen_funcs: set[str] = set()
|
||||
|
||||
def walk(body: list[ast.stmt], current_module_qn: str) -> None:
|
||||
@@ -176,19 +185,30 @@ def parse_module(path: Path, module_qn: str) -> ModuleStubs:
|
||||
short_name=name,
|
||||
module_qn=current_module_qn,
|
||||
))
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
# Track `from X import *` for re-export resolution.
|
||||
if node.module and any(alias.name == "*" for alias in node.names):
|
||||
star_imports.append(StarReExport(target_module=node.module))
|
||||
elif isinstance(node, ast.If):
|
||||
# Flatten version guards.
|
||||
walk(node.body, current_module_qn)
|
||||
walk(node.orelse, current_module_qn)
|
||||
|
||||
walk(tree.body, module_qn)
|
||||
return ModuleStubs(module_qn=module_qn, classes=classes, functions=functions)
|
||||
return ModuleStubs(module_qn=module_qn, classes=classes, functions=functions), star_imports
|
||||
|
||||
|
||||
def iter_module_stubs(stdlib_root: Path) -> Iterable[ModuleStubs]:
|
||||
def collect_all_stubs(stdlib_root: Path) -> tuple[dict[str, ModuleStubs], dict[str, list[StarReExport]]]:
|
||||
"""Gather every stub in the allowlist plus any re-export targets they
|
||||
pull in transitively (e.g. os.path -> posixpath -> genericpath).
|
||||
Returns (modules, star_imports_per_module). """
|
||||
modules: dict[str, ModuleStubs] = {}
|
||||
star_imports: dict[str, list[StarReExport]] = {}
|
||||
|
||||
# First pass: walk allowlist
|
||||
queue: list[tuple[Path, str]] = []
|
||||
for path in sorted(stdlib_root.rglob("*.pyi")):
|
||||
rel = path.relative_to(stdlib_root)
|
||||
# Map file path to module qn.
|
||||
parts = list(rel.parts)
|
||||
if parts[-1] == "__init__.pyi":
|
||||
parts.pop()
|
||||
@@ -202,7 +222,80 @@ def iter_module_stubs(stdlib_root: Path) -> Iterable[ModuleStubs]:
|
||||
if top not in ALLOWED_MODULES:
|
||||
continue
|
||||
module_qn = ".".join(parts)
|
||||
yield parse_module(path, module_qn)
|
||||
queue.append((path, module_qn))
|
||||
|
||||
seen_targets: set[str] = set()
|
||||
while queue:
|
||||
path, mod_qn = queue.pop()
|
||||
if mod_qn in modules:
|
||||
continue
|
||||
ms, stars = parse_module(path, mod_qn)
|
||||
modules[mod_qn] = ms
|
||||
star_imports[mod_qn] = stars
|
||||
# Enqueue re-export targets (e.g. posixpath, ntpath, genericpath)
|
||||
for star in stars:
|
||||
tgt = star.target_module
|
||||
if tgt in seen_targets:
|
||||
continue
|
||||
seen_targets.add(tgt)
|
||||
tgt_path = stdlib_root / (tgt.replace(".", "/") + ".pyi")
|
||||
if tgt_path.is_file():
|
||||
queue.append((tgt_path, tgt))
|
||||
else:
|
||||
tgt_init = stdlib_root / tgt.replace(".", "/") / "__init__.pyi"
|
||||
if tgt_init.is_file():
|
||||
queue.append((tgt_init, tgt))
|
||||
|
||||
return modules, star_imports
|
||||
|
||||
|
||||
def resolve_reexports(modules: dict[str, ModuleStubs],
|
||||
star_imports: dict[str, list[StarReExport]]) -> None:
|
||||
"""For each module with `from X import *`, copy X's classes/functions
|
||||
into the current module under that module's QN. Iterates to a fixed
|
||||
point to handle re-export chains. """
|
||||
changed = True
|
||||
iter_count = 0
|
||||
while changed and iter_count < 8:
|
||||
changed = False
|
||||
iter_count += 1
|
||||
for mod_qn, stars in star_imports.items():
|
||||
ms = modules[mod_qn]
|
||||
for star in stars:
|
||||
target = modules.get(star.target_module)
|
||||
if not target:
|
||||
continue
|
||||
# Copy target's classes/functions under mod_qn
|
||||
existing_class_names = {c.short_name for c in ms.classes}
|
||||
existing_func_names = {f.short_name for f in ms.functions}
|
||||
for c in target.classes:
|
||||
if c.short_name in existing_class_names:
|
||||
continue
|
||||
ms.classes.append(StubClass(
|
||||
qualified_name=f"{mod_qn}.{c.short_name}",
|
||||
short_name=c.short_name,
|
||||
methods=list(c.methods),
|
||||
bases=list(c.bases),
|
||||
))
|
||||
existing_class_names.add(c.short_name)
|
||||
changed = True
|
||||
for f in target.functions:
|
||||
if f.short_name in existing_func_names:
|
||||
continue
|
||||
ms.functions.append(StubFunction(
|
||||
qualified_name=f"{mod_qn}.{f.short_name}",
|
||||
short_name=f.short_name,
|
||||
module_qn=mod_qn,
|
||||
))
|
||||
existing_func_names.add(f.short_name)
|
||||
changed = True
|
||||
|
||||
|
||||
def iter_module_stubs(stdlib_root: Path) -> Iterable[ModuleStubs]:
|
||||
modules, star_imports = collect_all_stubs(stdlib_root)
|
||||
resolve_reexports(modules, star_imports)
|
||||
for mod_qn in sorted(modules.keys()):
|
||||
yield modules[mod_qn]
|
||||
|
||||
|
||||
C_HEADER = """\
|
||||
|
||||
@@ -725,6 +725,120 @@ TEST(pylsp_stdlib_logging_getlogger) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ── Round 1 — parity push ────────────────────────────────────── */
|
||||
|
||||
TEST(pylsp_round1_dotted_import_walk) {
|
||||
/* `import os.path` — `os` and `os.path` should both be navigable
|
||||
* through attribute access so `os.path.join('a', 'b')` resolves to
|
||||
* the registered os.path.join function. */
|
||||
CBMFileResult *r = extract_py(
|
||||
"import os.path\n"
|
||||
"def use():\n"
|
||||
" return os.path.join('a', 'b')\n");
|
||||
ASSERT_NOT_NULL(r);
|
||||
int idx = find_resolved(r, "use", "join");
|
||||
ASSERT_GTE(idx, 0);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(pylsp_round1_typing_cast) {
|
||||
/* cast(Foo, x) returns NAMED("Foo"), enabling subsequent method
|
||||
* dispatch to resolve. */
|
||||
CBMFileResult *r = extract_py(
|
||||
"from typing import cast\n"
|
||||
"class Foo:\n"
|
||||
" def m(self):\n"
|
||||
" return 1\n"
|
||||
"def use(x):\n"
|
||||
" f = cast(Foo, x)\n"
|
||||
" return f.m()\n");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_GTE(require_resolved(r, "use", "m"), 0);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(pylsp_round1_assert_type_passthrough) {
|
||||
/* assert_type(x, T) is a no-op at runtime; the returned value's type
|
||||
* is unchanged. We type the result as type-of(x). */
|
||||
CBMFileResult *r = extract_py(
|
||||
"from typing import assert_type\n"
|
||||
"class Foo:\n"
|
||||
" def m(self):\n"
|
||||
" return 1\n"
|
||||
"def use(x: Foo):\n"
|
||||
" f = assert_type(x, Foo)\n"
|
||||
" return f.m()\n");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_GTE(require_resolved(r, "use", "m"), 0);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(pylsp_round1_forward_reference) {
|
||||
/* def f(x: "Foo") — quoted annotation must resolve as if unquoted. */
|
||||
CBMFileResult *r = extract_py(
|
||||
"class Foo:\n"
|
||||
" def m(self):\n"
|
||||
" return 1\n"
|
||||
"def use(x: \"Foo\"):\n"
|
||||
" return x.m()\n");
|
||||
ASSERT_NOT_NULL(r);
|
||||
ASSERT_GTE(require_resolved(r, "use", "m"), 0);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(pylsp_round1_self_return_chains) {
|
||||
/* class Builder:
|
||||
* def step1(self) -> Self: return self
|
||||
* def step2(self) -> Self: return self
|
||||
* def build(self): return ...
|
||||
* Builder().step1().step2().build() — must chain through Self. */
|
||||
CBMFileResult *r = extract_py(
|
||||
"from typing import Self\n"
|
||||
"class Builder:\n"
|
||||
" def step1(self) -> Self:\n"
|
||||
" return self\n"
|
||||
" def step2(self) -> Self:\n"
|
||||
" return self\n"
|
||||
" def build(self):\n"
|
||||
" return 1\n"
|
||||
"def use():\n"
|
||||
" return Builder().step1().step2().build()\n");
|
||||
ASSERT_NOT_NULL(r);
|
||||
/* Each chain link should resolve. We assert the final .build() does. */
|
||||
ASSERT_GTE(require_resolved(r, "use", "build"), 0);
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(pylsp_round1_generic_subscript_annotation) {
|
||||
/* `def f(items: list[Foo])` — the generic subscript should not
|
||||
* confuse annotation resolution; we drop the [Foo] part for v1. */
|
||||
CBMFileResult *r = extract_py(
|
||||
"from typing import Optional\n"
|
||||
"class Foo:\n"
|
||||
" def m(self):\n"
|
||||
" return 1\n"
|
||||
"def use(x: Optional[Foo]):\n"
|
||||
" return x.m()\n");
|
||||
ASSERT_NOT_NULL(r);
|
||||
/* x has type Optional which strips to Optional, then we look up
|
||||
* .m on it. This SHOULD NOT resolve in v1 since Optional is just
|
||||
* Union — but it shouldn't crash either. We assert no false-positive
|
||||
* high-confidence resolution against an unrelated method. */
|
||||
int idx = find_resolved(r, "use", "m");
|
||||
if (idx >= 0) {
|
||||
const CBMResolvedCall *rc = &r->resolved_calls.items[idx];
|
||||
/* If we did resolve, must be against Foo, not something garbage. */
|
||||
ASSERT(strstr(rc->callee_qn, "Foo") != NULL);
|
||||
}
|
||||
cbm_free_result(r);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ── Suite ─────────────────────────────────────────────────────── */
|
||||
|
||||
SUITE(py_lsp) {
|
||||
@@ -768,4 +882,11 @@ SUITE(py_lsp) {
|
||||
RUN_TEST(pylsp_stdlib_collections_defaultdict);
|
||||
RUN_TEST(pylsp_stdlib_pathlib_path_method);
|
||||
RUN_TEST(pylsp_stdlib_logging_getlogger);
|
||||
/* Round 1 — parity push */
|
||||
RUN_TEST(pylsp_round1_dotted_import_walk);
|
||||
RUN_TEST(pylsp_round1_typing_cast);
|
||||
RUN_TEST(pylsp_round1_assert_type_passthrough);
|
||||
RUN_TEST(pylsp_round1_forward_reference);
|
||||
RUN_TEST(pylsp_round1_self_return_chains);
|
||||
RUN_TEST(pylsp_round1_generic_subscript_annotation);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user