Skip to content

Commit 87e3efe

Browse files
authored
Merge pull request #1049 from DeusData/fix/988-py-alias-import
fix(py-lsp): resolve calls through aliased imports
2 parents a688d85 + 0ec2478 commit 87e3efe

4 files changed

Lines changed: 118 additions & 13 deletions

File tree

internal/cbm/lsp/py_lsp.c

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -206,8 +206,19 @@ void py_lsp_bind_imports(PyLSPContext *ctx) {
206206
// resolution checks the registry to upgrade to MODULE / class
207207
// / function as appropriate.
208208
t = cbm_type_named(ctx->arena, qn);
209+
} else if (strchr(qn, '.') != NULL) {
210+
// Dotted path whose tail does NOT match the local name: an
211+
// ALIASED binding — `from X import Y as Z` (Z names function/
212+
// class X.Y) or `import a.b as z` (z names module a.b). The
213+
// CBMImport shape cannot distinguish the two, but NAMED(qn)
214+
// covers both: phase 6 upgrades it to the registered function/
215+
// class for the from-import and to MODULE for the module alias.
216+
// Binding MODULE here made `g()` calls on `from m import f as g`
217+
// resolve as calls on a module — lsp=MISS, and the whole CALLS
218+
// edge was lost (#988).
219+
t = cbm_type_named(ctx->arena, qn);
209220
} else {
210-
// `import X` / `import X as Y` — bind to MODULE(X).
221+
// `import X` / `import X as Y` (single segment) — MODULE(X).
211222
t = cbm_type_module(ctx->arena, qn);
212223
}
213224
py_scope_bind(ctx, local, t);
@@ -1910,11 +1921,23 @@ static void py_emit_call_for(PyLSPContext *ctx, TSNode call_node) {
19101921
py_scope_restore(ctx, saved);
19111922
return;
19121923
}
1913-
// Constructor call (ClassName())
1924+
// Constructor call (ClassName()) — or a call through any other
1925+
// NAMED scope binding (aliased imports land here too).
19141926
const CBMType *in_scope = cbm_scope_lookup(ctx->current_scope, fname);
19151927
if (!cbm_type_is_unknown(in_scope) && in_scope->kind == CBM_TYPE_NAMED) {
19161928
const char *qn = in_scope->data.named.qualified_name;
1917-
py_emit_resolved_call(ctx, qn, "lsp_constructor", 0.85f);
1929+
const char *tail = strrchr(qn, '.');
1930+
const char *qn_short = tail ? tail + 1 : qn;
1931+
if (strcmp(qn_short, fname) != 0) {
1932+
/* Aliased binding (`from m import f as g; g()`): the textual
1933+
* callee ("g") differs from the resolved QN's tail ("f"), so
1934+
* the pass join would never match them. Stash the textual
1935+
* name in `reason` under a join-gated strategy, mirroring
1936+
* lsp_dict_dispatch (see lsp_resolve.h) (#988). */
1937+
py_emit_resolved_call_reason(ctx, qn, "lsp_import_alias", 0.85f, fname);
1938+
} else {
1939+
py_emit_resolved_call(ctx, qn, "lsp_constructor", 0.85f);
1940+
}
19181941
return;
19191942
}
19201943
// Module-local function

src/pipeline/lsp_resolve.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,7 @@ static inline const CBMResolvedCall *cbm_pipeline_find_lsp_resolution(
174174
strcmp(rc->strategy, "lsp_method_ref_ctor") == 0 ||
175175
strcmp(rc->strategy, "lsp_method_ref_ctor_synth") == 0 ||
176176
strcmp(rc->strategy, "lsp_dict_dispatch") == 0 ||
177+
strcmp(rc->strategy, "lsp_import_alias") == 0 ||
177178
strcmp(rc->strategy, "lsp_destructor") == 0 ||
178179
strcmp(rc->strategy, "php_method_dynamic") == 0) &&
179180
strcmp(cbm_lsp_bare_segment(rc->reason), call_short) == 0)) {

tests/test_lang_contract.c

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1207,6 +1207,41 @@ TEST(contract_edge_imports_alias_resolves_real_file_issue767) {
12071207
PASS();
12081208
}
12091209

1210+
/* True if a CALLS edge exists whose source QN ends with `src_suffix` and
1211+
* target QN ends with `tgt_suffix`. */
1212+
static int calls_edge_between(cbm_store_t *store, const char *project, const char *src_suffix,
1213+
const char *tgt_suffix) {
1214+
cbm_edge_t *edges = NULL;
1215+
int n = 0;
1216+
if (cbm_store_find_edges_by_type(store, project, "CALLS", &edges, &n) != CBM_STORE_OK)
1217+
return 0;
1218+
int found = 0;
1219+
size_t ssl = strlen(src_suffix);
1220+
size_t tsl = strlen(tgt_suffix);
1221+
for (int i = 0; i < n && !found; i++) {
1222+
cbm_node_t s, t;
1223+
if (cbm_store_find_node_by_id(store, edges[i].source_id, &s) != CBM_STORE_OK)
1224+
continue;
1225+
if (cbm_store_find_node_by_id(store, edges[i].target_id, &t) != CBM_STORE_OK) {
1226+
cbm_node_free_fields(&s);
1227+
continue;
1228+
}
1229+
const char *sq = s.qualified_name;
1230+
const char *tq = t.qualified_name;
1231+
if (sq && tq) {
1232+
size_t sql = strlen(sq);
1233+
size_t tql = strlen(tq);
1234+
if (sql >= ssl && strcmp(sq + sql - ssl, src_suffix) == 0 && tql >= tsl &&
1235+
strcmp(tq + tql - tsl, tgt_suffix) == 0)
1236+
found = 1;
1237+
}
1238+
cbm_node_free_fields(&s);
1239+
cbm_node_free_fields(&t);
1240+
}
1241+
cbm_store_free_edges(edges, n);
1242+
return found;
1243+
}
1244+
12101245
/* #999: URLs in CI/tooling configs (.pre-commit-config.yaml, .github/
12111246
* workflows) are repository references for TOOLING, not endpoints this
12121247
* service exposes. They must not mint __route__infra__ Route nodes — the
@@ -1327,6 +1362,55 @@ static int calls_edge_targets(cbm_store_t *store, const char *project, const cha
13271362
return found;
13281363
}
13291364

1365+
/* #988: `from m import f as g; g()` produced NO CALLS edge — the Python LSP
1366+
* bound the alias as MODULE("m.f") (the from-style heuristic keys on the
1367+
* local name matching the module-path tail, which an alias never does), so
1368+
* the call missed, and the registry fallback did not cover the minimal
1369+
* two-file shape either. The alias must resolve exactly like the plain
1370+
* import. The other import forms are pinned alongside as invariance guards
1371+
* (all resolve on main today and must keep resolving). */
1372+
TEST(contract_edge_python_aliased_import_call_resolves_issue988) {
1373+
LangProj lp;
1374+
static const LangFile f[] = {
1375+
{"m.py", "def f(x):\n return x + 1\n"},
1376+
{"pkg/__init__.py", ""},
1377+
{"pkg/dm.py", "def h(x):\n return x\n"},
1378+
{"caller_alias.py", "from m import f as g\n"
1379+
"\n"
1380+
"def use_alias(x):\n"
1381+
" return g(x)\n"},
1382+
{"caller_plain.py", "from m import f\n"
1383+
"\n"
1384+
"def use_plain(x):\n"
1385+
" return f(x)\n"},
1386+
{"caller_modalias.py", "import m as mm\n"
1387+
"\n"
1388+
"def use_modalias(x):\n"
1389+
" return mm.f(x)\n"},
1390+
{"caller_dotalias.py", "import pkg.dm as dz\n"
1391+
"\n"
1392+
"def use_dotalias(x):\n"
1393+
" return dz.h(x)\n"}};
1394+
cbm_store_t *store = lang_index_files(&lp, f, 7);
1395+
ASSERT_TRUE(store != NULL);
1396+
int alias = calls_edge_between(store, lp.project, ".use_alias", ".m.f");
1397+
int plain = calls_edge_between(store, lp.project, ".use_plain", ".m.f");
1398+
int modalias = calls_edge_between(store, lp.project, ".use_modalias", ".m.f");
1399+
int dotalias = calls_edge_between(store, lp.project, ".use_dotalias", ".pkg.dm.h");
1400+
if (!alias || !plain || !modalias || !dotalias) {
1401+
fprintf(stderr,
1402+
" [988] FAIL alias=%d plain=%d modalias=%d dotalias=%d (all import forms "
1403+
"must produce the CALLS edge)\n",
1404+
alias, plain, modalias, dotalias);
1405+
}
1406+
ASSERT_TRUE(alias); /* the #988 bug: aliased from-import call lost */
1407+
ASSERT_TRUE(plain);
1408+
ASSERT_TRUE(modalias);
1409+
ASSERT_TRUE(dotalias);
1410+
lang_cleanup(&lp, store);
1411+
PASS();
1412+
}
1413+
13301414
/* #871: a CommonJS require() binding shadowed call resolution. `const doThing
13311415
* = require("../bs/doThing")` emitted a Variable def for `doThing` in the
13321416
* importing module, so the call `doThing(...)` resolved to that same-module
@@ -1572,6 +1656,7 @@ SUITE(lang_contract) {
15721656
RUN_TEST(contract_edge_workspaces_imports_issue408);
15731657
RUN_TEST(contract_edge_imports_alias_no_phantom_folder_edge_issue767);
15741658
RUN_TEST(contract_edge_imports_alias_resolves_real_file_issue767);
1659+
RUN_TEST(contract_edge_python_aliased_import_call_resolves_issue988);
15751660
RUN_TEST(contract_edge_no_infra_routes_from_ci_configs_issue999);
15761661
RUN_TEST(contract_edge_infra_routes_from_deploy_configs_still_minted);
15771662
RUN_TEST(contract_edge_commonjs_require_call_resolves_issue871);

tests/test_py_lsp.c

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -192,16 +192,12 @@ TEST(pylsp_import_from_aliased) {
192192
const char *qns[] = {"pathlib.Path"};
193193
bind_imports_into_ctx(&ctx, &a, &reg, locals, qns, 1);
194194
const CBMType *t = py_lsp_lookup_in_scope(&ctx, "P");
195-
/* Aliased name doesn't end module_qn with .P, so this binds as MODULE.
196-
* Phase 6 registry lookup will downgrade to NAMED if registry has no
197-
* matching module entry. Both behaviors are acceptable for v1; the
198-
* test asserts the entry exists with the correct QN. */
199-
ASSERT(t->kind == CBM_TYPE_NAMED || t->kind == CBM_TYPE_MODULE);
200-
if (t->kind == CBM_TYPE_NAMED) {
201-
ASSERT_STR_EQ(t->data.named.qualified_name, "pathlib.Path");
202-
} else {
203-
ASSERT_STR_EQ(t->data.module.module_qn, "pathlib.Path");
204-
}
195+
/* #988: an aliased from-import MUST bind NAMED (phase 6 upgrades it to
196+
* the registered function/class/module). The earlier MODULE binding made
197+
* `g()` calls on `from m import f as g` resolve as calls on a module —
198+
* lsp=MISS and the CALLS edge was lost. */
199+
ASSERT_EQ(t->kind, CBM_TYPE_NAMED);
200+
ASSERT_STR_EQ(t->data.named.qualified_name, "pathlib.Path");
205201
cbm_arena_destroy(&a);
206202
PASS();
207203
}

0 commit comments

Comments
 (0)