Skip to content
Open
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
54 changes: 54 additions & 0 deletions internal/cbm/lsp/ts_lsp.c
Original file line number Diff line number Diff line change
Expand Up @@ -5317,6 +5317,54 @@ static const CBMType *interface_method_signature_from_ast(TSLSPContext *ctx, TSN
return cbm_type_func(ctx->arena, NULL, param_count ? param_types : NULL, return_types);
}

// `constructor(private readonly svc: Svc)` declares the field `svc`. A parameter
// is a property only when it carries an accessibility modifier, `readonly` or
// `override`; a plain parameter stays local to the constructor.
static int collect_parameter_properties(TSLSPContext *ctx, TSNode method, const char **names,
const CBMType **types, int count, int max_count) {
TSNode name = ts_node_child_by_field_name(method, "name", TS_LSP_FIELD_LEN("name"));
char *method_name = ts_node_is_null(name) ? NULL : node_text(ctx, name);
if (!method_name || strcmp(method_name, "constructor") != 0)
return count;
TSNode params =
ts_node_child_by_field_name(method, "parameters", TS_LSP_FIELD_LEN("parameters"));
if (ts_node_is_null(params))
return count;

uint32_t param_total = ts_node_named_child_count(params);
for (uint32_t i = 0; i < param_total && count < max_count; i++) {
TSNode param = ts_node_named_child(params, i);
const char *kind = ts_node_type(param);
if (strcmp(kind, "required_parameter") != 0 && strcmp(kind, "optional_parameter") != 0)
continue;
bool is_property = false;
uint32_t child_total = ts_node_child_count(param);
for (uint32_t j = 0; j < child_total && !is_property; j++) {
const char *ck = ts_node_type(ts_node_child(param, j));
is_property = strcmp(ck, "accessibility_modifier") == 0 ||
strcmp(ck, "override_modifier") == 0 || strcmp(ck, "readonly") == 0;
}
TSNode pattern = ts_node_child_by_field_name(param, "pattern", TS_LSP_FIELD_LEN("pattern"));
if (!is_property || ts_node_is_null(pattern) ||
strcmp(ts_node_type(pattern), "identifier") != 0)
continue;
char *field_name = node_text(ctx, pattern);
if (!field_name)
continue;
const CBMType *field_type = cbm_type_unknown();
TSNode annotation = ts_node_child_by_field_name(param, "type", TS_LSP_FIELD_LEN("type"));
if (!ts_node_is_null(annotation)) {
TSNode type_node = ts_node_named_child(annotation, 0);
if (!ts_node_is_null(type_node))
field_type = ts_parse_type_node(ctx, type_node);
}
names[count] = field_name;
types[count] = field_type;
count++;
}
return count;
}

// AST sweep: walk class/interface bodies to collect field names+types and refine
// embedded_types / type_param_names.
static void ast_sweep_shapes(TSLSPContext *ctx, TSNode root, CBMTypeRegistry *reg) {
Expand Down Expand Up @@ -5447,6 +5495,12 @@ static void ast_sweep_shapes(TSLSPContext *ctx, TSNode root, CBMTypeRegistry *re
continue;
}

if (!is_interface && strcmp(mk, "method_definition") == 0) {
field_count =
collect_parameter_properties(ctx, m, field_names, field_types, field_count, 63);
continue;
}

// Interface method signature: `methodName(params): ReturnType;`
// Or class method declaration in `.d.ts` ambient mode.
if (is_interface && (strcmp(mk, "method_signature") == 0)) {
Expand Down
13 changes: 8 additions & 5 deletions src/pipeline/fqn.c
Original file line number Diff line number Diff line change
Expand Up @@ -282,11 +282,14 @@ static char *resolve_python_relative(char *buf, size_t buf_size, const char *mod
return strdup(buf);
}

/* Strip an explicit JS/TS module file extension while preserving dots that are
* part of an extensionless basename (e.g. "featureX.engine"). */
static size_t strip_js_module_ext(const char *seg_start, size_t seg_len) {
/* An explicit list, not the language table: the table also claims suffixes such
* as `.config` and `.d` that are ordinary parts of a dotted basename
* (`app.config`, `types.d`). Component files are imported with their
* extension spelled out, so they are listed beside the JS/TS ones. */
size_t cbm_pipeline_strip_module_ext(const char *seg_start, size_t seg_len) {
static const char *const extensions[] = {
".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts", ".json",
".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts",
".cts", ".json", ".vue", ".svelte", ".astro", ".mdx",
};
for (size_t i = 0; i < sizeof(extensions) / sizeof(extensions[0]); i++) {
size_t ext_len = strlen(extensions[i]);
Expand Down Expand Up @@ -321,7 +324,7 @@ static char *resolve_js_relative(char *buf, size_t buf_size, const char *module_
continue;
}
if (*p == '\0') {
seg_len = strip_js_module_ext(seg_start, seg_len);
seg_len = cbm_pipeline_strip_module_ext(seg_start, seg_len);
}
if (seg_len > 0 && !path_append_segment(buf, buf_size, seg_start, seg_len)) {
return NULL;
Expand Down
5 changes: 4 additions & 1 deletion src/pipeline/pass_pkgmap.c
Original file line number Diff line number Diff line change
Expand Up @@ -1414,7 +1414,10 @@ char *cbm_pipeline_resolve_module(const cbm_pipeline_ctx_t *ctx, const char *sou
if (amap) {
char *aliased = cbm_path_alias_resolve(amap, module_path);
if (aliased) {
char *qn = cbm_pipeline_fqn_module(ctx->project_name, aliased);
/* As in step 1: the alias resolver already removed the file
* extension, so a dotted basename such as `x.service` must not
* be stripped again. */
char *qn = cbm_pipeline_fqn_folder(ctx->project_name, aliased);
Comment thread
thientrile marked this conversation as resolved.
Resolved
free(aliased);
return qn;
}
Expand Down
17 changes: 6 additions & 11 deletions src/pipeline/path_alias.c
Original file line number Diff line number Diff line change
Expand Up @@ -47,21 +47,16 @@ enum {

/* ── Helpers ───────────────────────────────────────────────────── */

/* Strip .ts/.tsx/.js/.jsx in place. Returns its argument. */
/* Strip an explicit module extension in place, keeping the dots of an
* extensionless basename such as `x.service`; the caller does not strip a
* second time. Returns its argument. */
static char *strip_resolved_ext(char *path) {
if (!path) {
return path;
}
size_t len = strlen(path);
if (len > 3 && path[len - 3] == '.' && (path[len - 2] == 't' || path[len - 2] == 'j') &&
path[len - 1] == 's') {
path[len - 3] = '\0';
return path;
}
if (len > 4 && path[len - 4] == '.' && (path[len - 3] == 't' || path[len - 3] == 'j') &&
path[len - 2] == 's' && path[len - 1] == 'x') {
path[len - 4] = '\0';
}
char *last_slash = strrchr(path, '/');
char *segment = last_slash ? last_slash + 1 : path;
segment[cbm_pipeline_strip_module_ext(segment, strlen(segment))] = '\0';
return path;
}

Expand Down
7 changes: 7 additions & 0 deletions src/pipeline/pipeline.h
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
#define CBM_PIPELINE_H

#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdatomic.h>

Expand Down Expand Up @@ -201,6 +202,12 @@ char *cbm_pipeline_fqn_folder(const char *project, const char *rel_dir);
* dot-only segments used by Python relative imports. */
char *cbm_pipeline_resolve_relative_import(const char *source_rel, const char *module_path);

/* Length of the last path segment [seg_start, seg_start + seg_len) without an
* explicit module file extension (`.ts`, `.vue`, ...). Dots that belong to an
* extensionless basename such as `x.service` are kept. Every import resolver
* strips through this one list so their module QNs agree. */
size_t cbm_pipeline_strip_module_ext(const char *seg_start, size_t seg_len);

/* Derive project name from an absolute path.
* Replaces / and : with -, collapses --, trims leading -.
* Caller must free() the returned string. */
Expand Down
63 changes: 63 additions & 0 deletions tests/test_edge_imports.c
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,68 @@ TEST(ei_typescript_dotted_relative_import_targets_source_module_issue1682) {
PASS();
}

/* The #1682 dotted-basename fix, through a tsconfig `paths` alias. The alias
* branch stripped `.service` from `src/conversations/conversations.service`,
* missed the module, and bound the import to an unrelated file that also
* mentions the class (the NestJS `x.service.ts` layout hits this on every
* aliased import). */
TEST(ei_typescript_dotted_alias_import_targets_source_module) {
static const char *service_path = "src/conversations/conversations.service.ts";
static const char *consumer_path = "src/scrm/customers/scrm-interaction.service.ts";
static const EILangFile f[] = {
{"tsconfig.json",
"{ \"compilerOptions\": { \"baseUrl\": \"./\", \"paths\": { \"src/*\": [\"src/*\"] } } }\n"},
{"src/conversations/conversations.service.ts",
"export class ConversationsService {\n"
" addTagsToConversation(dto: string): string { return dto; }\n"
"}\n"},
{"src/tasks/capabilities/task-write-capabilities.spec.ts",
"import { ConversationsService } from 'src/conversations/conversations.service';\n"
"export function ConversationsServiceFixture(): ConversationsService {\n"
" return new ConversationsService();\n"
"}\n"},
{"src/scrm/customers/scrm-interaction.service.ts",
"import { ConversationsService } from 'src/conversations/conversations.service';\n"
"export class ScrmInteractionService {\n"
" run(s: ConversationsService): string { return s.addTagsToConversation('x'); }\n"
"}\n"},
};

EILangProj lp;
cbm_store_t *store = ei_index_files(&lp, f, (int)(sizeof(f) / sizeof(f[0])));
ASSERT_NOT_NULL(store);

int64_t consumer_id = ei_node_id_for_file_label(store, lp.project, consumer_path, "File");
ASSERT_GT(consumer_id, 0);

cbm_edge_t *edges = NULL;
int edge_count = 0;
ASSERT_EQ(
cbm_store_find_edges_by_source_type(store, consumer_id, "IMPORTS", &edges, &edge_count),
CBM_STORE_OK);

bool saw_import = false;
bool target_ok = false;
for (int i = 0; i < edge_count; i++) {
const char *props = edges[i].properties_json ? edges[i].properties_json : "";
if (!strstr(props, "\"local_name\":\"ConversationsService\"")) {
continue;
}
cbm_node_t *target = (cbm_node_t *)calloc(1, sizeof(cbm_node_t));
ASSERT_NOT_NULL(target);
ASSERT_EQ(cbm_store_find_node_by_id(store, edges[i].target_id, target), CBM_STORE_OK);
saw_import = true;
target_ok = target->file_path && strcmp(target->file_path, service_path) == 0;
cbm_store_free_nodes(target, 1);
}
cbm_store_free_edges(edges, edge_count);
ei_cleanup(&lp, store);

ASSERT_TRUE(saw_import);
ASSERT_TRUE(target_ok);
PASS();
}

/* TypeScript: default import `import helper from './util'`. */
TEST(ei_typescript_default_import) {
static const EILangFile f[] = {
Expand Down Expand Up @@ -1154,6 +1216,7 @@ SUITE(edge_imports) {
/* ── GREEN GUARDS — TypeScript (must stay passing) ── */
RUN_TEST(ei_typescript_named_relative_import);
RUN_TEST(ei_typescript_dotted_relative_import_targets_source_module_issue1682);
RUN_TEST(ei_typescript_dotted_alias_import_targets_source_module);
RUN_TEST(ei_typescript_default_import);
RUN_TEST(ei_typescript_namespace_import);
RUN_TEST(ei_typescript_aliased_import);
Expand Down
4 changes: 4 additions & 0 deletions tests/test_fqn.c
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ TEST(fqn_relative_js_import_preserves_dotted_basename_issue1682) {
ASSERT_FQN(cbm_pipeline_resolve_relative_import(
"packages/api/src/modules/moduleA/moduleA.service.ts", "./create-thing.dto"),
"packages/api/src/modules/moduleA/create-thing.dto");
/* A spelled-out component extension is stripped, matching the Vue file's
* module QN. */
ASSERT_FQN(cbm_pipeline_resolve_relative_import("web/src/App.ts", "./components/Foo.vue"),
"web/src/components/Foo");
PASS();
}

Expand Down
21 changes: 21 additions & 0 deletions tests/test_path_alias.c
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,26 @@ TEST(path_alias_strips_ext) {
PASS();
}

/* The resolved path becomes a module QN with no further stripping, so a spelled
* out component extension must go while a dotted basename stays. */
TEST(path_alias_strips_component_ext_keeps_dotted_basename) {
cbm_path_alias_map_t *m = make_map(NULL, 1, "@/*", "src/*");
char *vue = cbm_path_alias_resolve(m, "@/components/Foo.vue");
char *service = cbm_path_alias_resolve(m, "@/conversations/conversations.service");
char *config = cbm_path_alias_resolve(m, "@/config/app.config");
ASSERT_NOT_NULL(vue);
ASSERT_NOT_NULL(service);
ASSERT_NOT_NULL(config);
ASSERT_STR_EQ(vue, "src/components/Foo");
ASSERT_STR_EQ(service, "src/conversations/conversations.service");
ASSERT_STR_EQ(config, "src/config/app.config");
free(vue);
free(service);
free(config);
free_map(m);
PASS();
}

/* ── baseUrl fallback for non-relative, non-package imports ────── */

TEST(path_alias_baseurl_fallback) {
Expand Down Expand Up @@ -414,6 +434,7 @@ void suite_path_alias(void) {
RUN_TEST(path_alias_specificity_longest_first);
RUN_TEST(path_alias_exact_match);
RUN_TEST(path_alias_strips_ext);
RUN_TEST(path_alias_strips_component_ext_keeps_dotted_basename);
RUN_TEST(path_alias_baseurl_fallback);
RUN_TEST(path_alias_null_safety);
RUN_TEST(path_alias_find_for_file_nearest_ancestor);
Expand Down
18 changes: 18 additions & 0 deletions tests/test_ts_lsp.c
Original file line number Diff line number Diff line change
Expand Up @@ -4125,6 +4125,23 @@ TEST(tslsp_void_returning_method) {
PASS();
}

/* Constructor parameter properties are fields: NestJS injects every service as
* `constructor(private readonly svc: Svc)` and calls it as `this.svc.m()`. */
TEST(tslsp_constructor_parameter_property_resolution) {
CBMFileResult *r = extract_ts("class Svc { work(): void {} audit(): void {} }\n"
"class Logger { log(): void {} }\n"
"class Controller {\n"
" constructor(private readonly svc: Svc, public logger: Logger,\n"
" plain: Svc) {}\n"
" handle(): void { this.svc.work(); this.logger.log(); }\n"
"}\n");
ASSERT_NOT_NULL(r);
ASSERT_GTE(require_resolved(r, ".handle", "Svc.work"), 0);
ASSERT_GTE(require_resolved(r, ".handle", "Logger.log"), 0);
cbm_free_result(r);
PASS();
}

TEST(tslsp_nested_class_resolution) {
CBMFileResult *r = extract_ts("class Outer {\n"
" inner: Inner = new Inner();\n"
Expand Down Expand Up @@ -4695,6 +4712,7 @@ SUITE(ts_lsp) {
RUN_TEST(tslsp_in_operator_narrow_smoke);
RUN_TEST(tslsp_optional_property_chain);
RUN_TEST(tslsp_void_returning_method);
RUN_TEST(tslsp_constructor_parameter_property_resolution);
RUN_TEST(tslsp_nested_class_resolution);

/* Expression-eval complexity guard. */
Expand Down
Loading