Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 20 additions & 6 deletions internal/cbm/lsp/generated/rust_stdlib_data.c
Original file line number Diff line number Diff line change
Expand Up @@ -631,14 +631,28 @@ void cbm_rust_stdlib_register(CBMTypeRegistry* reg, CBMArena* arena) {

/* ── std::path ─────────────────────────────────────────────── */
ADD_TYPE("std.path.Path", "Path", false);
ADD_TYPE("std.path.PathBuf", "PathBuf", false);
{
/* PathBuf derefs to Path (#2053): `buf.join(..)` dispatches through
* rust_deref_step's DerefTarget entry to std.path.Path.join instead of
* leaving the receiver's method unknown. */
static const char *pathbuf_deref[] = {"DerefTarget:std.path.Path", NULL};
CBMRegisteredType _rt;
memset(&_rt, 0, sizeof(_rt));
_rt.qualified_name = "std.path.PathBuf";
_rt.short_name = "PathBuf";
_rt.embedded_types = pathbuf_deref;
cbm_registry_add_type(reg, _rt);
}
ADD_TYPE("std.path.Component", "Component", false);
ADD_TYPE("std.path.Components", "Components", false);
ADD_TYPE("std.path.Iter", "Iter", false);
ADD_TYPE("std.path.Display", "Display", false);

{
const char* T = "std.path.Path";
/* Owned-path producers return PathBuf (#2053), so a chained call
* (`root.to_path_buf().join(..)`) keeps a typed receiver. */
const CBMType* t_pathbuf = cbm_type_named(arena, "std.path.PathBuf");
ADD_FUNC(T, "new", "std.path.Path.new", cbm_type_unknown());
ADD_FUNC(T, "exists", "std.path.Path.exists", t_bool);
ADD_FUNC(T, "try_exists", "std.path.Path.try_exists", cbm_type_unknown());
Expand All @@ -656,16 +670,16 @@ void cbm_rust_stdlib_register(CBMTypeRegistry* reg, CBMArena* arena) {
ADD_FUNC(T, "parent", "std.path.Path.parent", cbm_type_unknown());
ADD_FUNC(T, "components", "std.path.Path.components", cbm_type_unknown());
ADD_FUNC(T, "iter", "std.path.Path.iter", cbm_type_unknown());
ADD_FUNC(T, "join", "std.path.Path.join", cbm_type_unknown());
ADD_FUNC(T, "join", "std.path.Path.join", t_pathbuf);
ADD_FUNC(T, "display", "std.path.Path.display", cbm_type_unknown());
ADD_FUNC(T, "canonicalize", "std.path.Path.canonicalize", cbm_type_unknown());
ADD_FUNC(T, "to_path_buf", "std.path.Path.to_path_buf", cbm_type_unknown());
ADD_FUNC(T, "to_path_buf", "std.path.Path.to_path_buf", t_pathbuf);
ADD_FUNC(T, "to_str", "std.path.Path.to_str", cbm_type_unknown());
ADD_FUNC(T, "to_string_lossy", "std.path.Path.to_string_lossy", cbm_type_unknown());
ADD_FUNC(T, "metadata", "std.path.Path.metadata", cbm_type_unknown());
ADD_FUNC(T, "read_dir", "std.path.Path.read_dir", cbm_type_unknown());
ADD_FUNC(T, "with_extension", "std.path.Path.with_extension", cbm_type_unknown());
ADD_FUNC(T, "with_file_name", "std.path.Path.with_file_name", cbm_type_unknown());
ADD_FUNC(T, "with_extension", "std.path.Path.with_extension", t_pathbuf);
ADD_FUNC(T, "with_file_name", "std.path.Path.with_file_name", t_pathbuf);
}
{
const char* T = "std.path.PathBuf";
Expand All @@ -676,7 +690,7 @@ void cbm_rust_stdlib_register(CBMTypeRegistry* reg, CBMArena* arena) {
ADD_FUNC(T, "pop", "std.path.PathBuf.pop", t_bool);
ADD_FUNC(T, "set_file_name", "std.path.PathBuf.set_file_name",t_unit);
ADD_FUNC(T, "set_extension", "std.path.PathBuf.set_extension",t_bool);
ADD_FUNC(T, "as_path", "std.path.PathBuf.as_path", cbm_type_unknown());
ADD_FUNC(T, "as_path", "std.path.PathBuf.as_path", cbm_type_named(arena, "std.path.Path"));
ADD_FUNC(T, "into_os_string", "std.path.PathBuf.into_os_string",cbm_type_unknown());
ADD_FUNC(T, "clear", "std.path.PathBuf.clear", t_unit);
}
Expand Down
41 changes: 41 additions & 0 deletions src/pipeline/lsp_resolve.h
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,47 @@ static inline bool cbm_pipeline_kotlin_external_target(CBMLanguage lang, const c
strncmp(callee_qn, "javax.", strlen("javax.")) == 0;
}

/* Rust (#2053): true when the Rust LSP positively resolved a call to a
* registered symbol that lives OUTSIDE the project — a std/core/alloc seed
* method (`root.join()` on `root: &Path` -> std.path.Path.join) or a seeded
* crate API. Such a row has no graph node, and before this gate the callers
* then fell back to the textual registry, which bound the call to whatever
* project method shares the leaf name (EvidenceTier::join, unique_name).
*
* The evidence is the strategy plus the QN's namespace. The listed strategies
* emit the QN of a function the LSP registry actually holds; project defs are
* registered under their project-prefixed QNs, so a registered QN outside the
* project prefix is an embedded seed by construction. A project-prefixed QN
* that merely failed the gbuf lookup is NOT external and keeps the #1085
* registry fallback, as does every unresolved/unknown-receiver row (those
* never reach the callers: they sit below CBM_LSP_CONFIDENCE_FLOOR).
* Rust-only: suppressors stay per-language. Pure; unit-tested. */
static inline bool cbm_pipeline_rust_external_target(CBMLanguage lang, const char *strategy,
const char *callee_qn,
const char *project_name) {
if (lang != CBM_LANG_RUST || !strategy || !callee_qn || !callee_qn[0] || !project_name ||
!project_name[0]) {
return false;
}
static const char *const registered_target_strategies[] = {
"lsp_method_dispatch", "lsp_trait_dispatch", "lsp_deref_dispatch", "lsp_bound_dispatch",
"lsp_direct", "lsp_ufcs", "lsp_constructor",
};
bool registered_target = false;
for (size_t i = 0;
i < sizeof(registered_target_strategies) / sizeof(registered_target_strategies[0]); i++) {
if (strcmp(strategy, registered_target_strategies[i]) == 0) {
registered_target = true;
break;
}
}
if (!registered_target) {
return false;
}
size_t proj_len = strlen(project_name);
return !(strncmp(callee_qn, project_name, proj_len) == 0 && callee_qn[proj_len] == '.');
}

/* Resolvers may report one graph target both project-relative and already
* project-prefixed. Raw string inequality is not ambiguity when both spellings
* resolve to the same materialized node; conversely, if both nodes exist they
Expand Down
14 changes: 12 additions & 2 deletions src/pipeline/pass_calls.c
Original file line number Diff line number Diff line change
Expand Up @@ -544,8 +544,18 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call,
}
}

cbm_resolution_t res = cbm_registry_resolve(ctx->registry, call->callee_name, module_qn,
imp_keys, imp_vals, imp_count);
/* #2053: a Rust call the LSP placed on an EXTERNAL symbol (std's
* Path::join, a seeded crate API) is resolved — it just has no graph node.
* The textual registry would bind it to a same-named project method
* instead, so skip it and let the empty-resolution service fallbacks below
* classify the call. MUST match pass_parallel.c. */
bool rust_external = lsp && cbm_pipeline_rust_external_target(
lang, lsp->strategy, lsp->callee_qn, ctx->project_name);
cbm_resolution_t res = {0};
if (!rust_external) {
res = cbm_registry_resolve(ctx->registry, call->callee_name, module_qn, imp_keys, imp_vals,
imp_count);
}
if (!res.qualified_name || res.qualified_name[0] == '\0') {
/* Resolution is empty when the callee belongs to an EXTERNAL client
* library whose source is not in the indexed tree (e.g. `requests.get`,
Expand Down
17 changes: 15 additions & 2 deletions src/pipeline/pass_parallel.c
Original file line number Diff line number Diff line change
Expand Up @@ -2895,7 +2895,15 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB
* semantic candidates are deliberately excluded: they require an
* exact LSP target and must fail closed rather than accepting a textual
* registry match. */
if ((!res.qualified_name || !res.qualified_name[0]) && !call->requires_lsp_resolution) {
/* #2053: a Rust call the LSP placed on an EXTERNAL symbol (std's
* Path::join, a seeded crate API) is resolved — it just has no graph
* node. The textual registry would bind it to a same-named project
* method instead, so it must not run for such a row. Mirrors
* pass_calls.c; the service fallbacks below still see the call. */
bool rust_external = lsp && cbm_pipeline_rust_external_target(
lang, lsp->strategy, lsp->callee_qn, rc->project_name);
if ((!res.qualified_name || !res.qualified_name[0]) && !call->requires_lsp_resolution &&
!rust_external) {
res = cbm_registry_resolve(rc->registry, call->callee_name, module_qn, imp_keys,
imp_vals, imp_count);
}
Expand Down Expand Up @@ -2989,9 +2997,14 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB
cbm_resolution_t fake_res = {.qualified_name = call->callee_name,
.confidence = PP_HALF_CONF,
.strategy = "callee_suffix"};
/* #2053: an LSP-external Rust call (`map.get(k)` on a std
* HashMap) reaches this branch only because its registry
* fallback was skipped. Without a route path the plain-CALLS
* fall-through would bind source -> source, a fabricated
* self-call, so it keeps only the route/service edges. */
emit_service_edge(ws->local_edge_buf, source_node, source_node, call, &fake_res,
module_qn, rc->registry, rc->main_gbuf, imp_keys, imp_vals,
imp_count, false);
imp_count, rust_external);
} else if (cbm_service_pattern_is_global_fetch(call->callee_name)) {
/* Native `fetch()` (#856): only the global API once resolution
* has failed to find a local/imported `fetch`. Call the low-level
Expand Down
35 changes: 35 additions & 0 deletions tests/test_parallel.c
Original file line number Diff line number Diff line change
Expand Up @@ -3121,6 +3121,40 @@ TEST(parallel_java_kotlin_lsp_override_cross_file_emits_lsp_strategy_edges) {
PASS();
}

/* #2053 contract for cbm_pipeline_rust_external_target: only a Rust row whose
* strategy names a registered target, and whose QN lies outside the project
* prefix, counts as external. The end-to-end probes live in test_pipeline.c
* (pipeline_rust_std_receiver_never_binds_project_method*). */
TEST(parallel_rust_external_target_contract) {
const char *proj = "proj";
/* External: registered std / seeded-crate targets. */
ASSERT_TRUE(cbm_pipeline_rust_external_target(CBM_LANG_RUST, "lsp_method_dispatch",
"std.path.Path.join", proj));
ASSERT_TRUE(cbm_pipeline_rust_external_target(CBM_LANG_RUST, "lsp_deref_dispatch",
"std.path.Path.join", proj));
ASSERT_TRUE(cbm_pipeline_rust_external_target(CBM_LANG_RUST, "lsp_constructor",
"core.sync.atomic.AtomicUsize.new", proj));
/* A prefix that is not a whole segment is still outside the project. */
ASSERT_TRUE(
cbm_pipeline_rust_external_target(CBM_LANG_RUST, "lsp_direct", "projx.helper", proj));
/* Project-prefixed targets keep the registry fallback. */
ASSERT_FALSE(cbm_pipeline_rust_external_target(CBM_LANG_RUST, "lsp_method_dispatch",
"proj.src.lib.EvidenceTier.join", proj));
/* Synthesized / non-registered strategies are not evidence. */
ASSERT_FALSE(cbm_pipeline_rust_external_target(CBM_LANG_RUST, "lsp_prelude_trait",
"std.path.PathBuf.clone", proj));
ASSERT_FALSE(
cbm_pipeline_rust_external_target(CBM_LANG_RUST, "lsp_unresolved", "root.join", proj));
/* Per-language: no other language is affected. */
ASSERT_FALSE(cbm_pipeline_rust_external_target(CBM_LANG_GO, "lsp_method_dispatch",
"std.path.Path.join", proj));
/* Defensive NULL/empty inputs. */
ASSERT_FALSE(cbm_pipeline_rust_external_target(CBM_LANG_RUST, NULL, "std.x", proj));
ASSERT_FALSE(cbm_pipeline_rust_external_target(CBM_LANG_RUST, "lsp_direct", "std.x", NULL));
ASSERT_FALSE(cbm_pipeline_rust_external_target(CBM_LANG_RUST, "lsp_direct", "", proj));
PASS();
}

/* Gate guard for the JVM-only unique-tail fallbacks (lsp_resolve.h).
*
* The tail fallbacks join LSP overrides across QN drift by unique
Expand Down Expand Up @@ -4426,6 +4460,7 @@ SUITE(parallel) {
RUN_TEST(parallel_go_cross_package_field_chain_resolves);
RUN_TEST(parallel_cross_file_reread_preserves_unretained_edges);
RUN_TEST(parallel_java_kotlin_lsp_override_cross_file_emits_lsp_strategy_edges);
RUN_TEST(parallel_rust_external_target_contract);
RUN_TEST(parallel_lsp_tail_match_fallbacks_gated_to_jvm);
RUN_TEST(parallel_calls_parity);
RUN_TEST(parallel_defines_parity);
Expand Down
107 changes: 107 additions & 0 deletions tests/test_pipeline.c
Original file line number Diff line number Diff line change
Expand Up @@ -5966,6 +5966,111 @@ TEST(pipeline_go_rw_usage_never_cross_into_c_parallel) {
PASS();
}

/* Fixture for the #2053 Rust std-receiver probes (sequential and parallel
* twins). The Rust LSP types `root: &Path` and dispatches `root.join(..)` to
* std's Path::join — a symbol with no graph node. Before the fix the pipeline
* then fell back to the textual registry, which bound the call to the only
* project method named `join` (EvidenceTier::join, unique_name). The
* call-expression receiver (`root.to_path_buf().join(..)`) is the same class
* one hop later: it needs to_path_buf's return type and PathBuf's Deref to
* Path. POSITIVE tripwires: the typed project call keeps its LSP edge, and a
* closure-parameter receiver the LSP cannot type still reaches the registry,
* so the fix removes only calls the LSP positively placed outside the
* project. `m.get(k)` on a std HashMap guards the parallel route fallback,
* which must not turn the skipped call into a self-call. pad_files > 0
* crosses the parallel-pipeline threshold. */
static void write_rust_std_receiver_fixture(const char *tmp, int pad_files) {
write_temp_file(tmp, "Cargo.toml",
"[package]\nname = \"stdrecv\"\nversion = \"0.1.0\"\nedition = \"2021\"\n");
write_temp_file(tmp, "src/lib.rs",
"use std::collections::HashMap;\n"
"use std::path::{Path, PathBuf};\n"
"\n"
"pub struct EvidenceTier(pub u8);\n"
"impl EvidenceTier {\n"
" pub fn join(&self, other: &EvidenceTier) -> EvidenceTier {\n"
" EvidenceTier(self.0.max(other.0))\n"
" }\n"
"}\n"
"\n"
"pub struct Gauge(pub u8);\n"
"impl Gauge {\n"
" pub fn recalibrate_gauge(&self) -> u8 {\n"
" self.0\n"
" }\n"
"}\n"
"\n"
"pub fn real_caller() -> EvidenceTier {\n"
" EvidenceTier(1).join(&EvidenceTier(2))\n"
"}\n"
"\n"
"pub fn stdlib_receiver(root: &Path) -> PathBuf {\n"
" root.join(\"subdir\")\n"
"}\n"
"\n"
"pub fn call_expr_receiver(root: &Path) -> PathBuf {\n"
" root.to_path_buf().join(\"nested\")\n"
"}\n"
"\n"
"pub fn untyped_receiver(gauges: &[Gauge]) -> u8 {\n"
" gauges.iter().map(|g| g.recalibrate_gauge()).sum()\n"
"}\n"
"\n"
"pub fn std_map_lookup(m: &HashMap<String, u8>, k: &str) -> Option<u8> {\n"
" m.get(k).copied()\n"
"}\n");
for (int i = 0; i < pad_files; i++) {
char name[64];
char body[128];
snprintf(name, sizeof(name), "src/filler%d.rs", i);
snprintf(body, sizeof(body), "pub fn filler%d() -> i32 {\n %d\n}\n", i, i);
write_temp_file(tmp, name, body);
}
}

static int run_rust_std_receiver_probe(int pad_files, const char *tag) {
char tmp[256];
snprintf(tmp, sizeof(tmp), "/tmp/cbm_rs_%s_XXXXXX", tag);
if (!cbm_mkdtemp(tmp)) {
FAIL("tmpdir");
}
write_rust_std_receiver_fixture(tmp, pad_files);

char db_path[512];
snprintf(db_path, sizeof(db_path), "%s/rs_std_recv.db", tmp);
cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
ASSERT_NOT_NULL(p);
ASSERT_EQ(cbm_pipeline_run(p), 0);
const char *project = cbm_pipeline_project_name(p);

cbm_store_t *s = cbm_store_open_path(db_path);
ASSERT_NOT_NULL(s);

/* NEGATIVE: std's Path::join / PathBuf::join are not project symbols. */
ASSERT_EQ(named_edge_count(s, project, "CALLS", "stdlib_receiver", "join"), 0);
ASSERT_EQ(named_edge_count(s, project, "CALLS", "call_expr_receiver", "join"), 0);
/* NEGATIVE: a std `map.get(k)` with no route path is no self-call (the
* parallel empty-resolution route fallback must not bind source->source). */
ASSERT_EQ(named_edge_count(s, project, "CALLS", "std_map_lookup", "std_map_lookup"), 0);
/* POSITIVE: the typed project call keeps its edge. */
ASSERT_EQ(named_edge_count(s, project, "CALLS", "real_caller", "join"), 1);
/* POSITIVE: an untyped receiver still reaches the registry fallback. */
ASSERT_EQ(named_edge_count(s, project, "CALLS", "untyped_receiver", "recalibrate_gauge"), 1);

cbm_store_close(s);
cbm_pipeline_free(p);
th_rmtree(tmp);
PASS();
}

TEST(pipeline_rust_std_receiver_never_binds_project_method) {
return run_rust_std_receiver_probe(0, "seq");
}

TEST(pipeline_rust_std_receiver_never_binds_project_method_parallel) {
return run_rust_std_receiver_probe(52, "par");
}

static int count_nodes_named(cbm_store_t *s, const char *project, const char *name);

/* Fixture for the #1942 bare-reference-vs-Field probes: a Go struct field
Expand Down Expand Up @@ -15120,6 +15225,8 @@ SUITE(pipeline) {
RUN_TEST(pipeline_html_embedded_member_call_stays_unbound);
RUN_TEST(pipeline_go_rw_usage_never_cross_into_c);
RUN_TEST(pipeline_go_rw_usage_never_cross_into_c_parallel);
RUN_TEST(pipeline_rust_std_receiver_never_binds_project_method);
RUN_TEST(pipeline_rust_std_receiver_never_binds_project_method_parallel);
RUN_TEST(pipeline_go_bare_ref_never_binds_field);
RUN_TEST(pipeline_go_bare_ref_never_binds_field_parallel);
RUN_TEST(pipeline_tsjs_receiver_parallel_keeps_service_edges);
Expand Down
Loading