diff --git a/src/discover/discover.h b/src/discover/discover.h index 85bc05294..f09766b1d 100644 --- a/src/discover/discover.h +++ b/src/discover/discover.h @@ -58,6 +58,12 @@ CBMLanguage cbm_disambiguate_frm(const char *path); * CBM_LANG_RESCRIPT. On read failure, defaults to CBM_LANG_RESCRIPT. */ CBMLanguage cbm_disambiguate_res(const char *path); +/* Disambiguate .d files by reading first 4KB of content. + * Returns CBM_LANG_COUNT (not source) for a make-style dependency file written + * by rustc/cargo, gcc/clang -MD or CMake ("target ...: prereq ..."), otherwise + * CBM_LANG_DLANG. On read failure or any doubt, defaults to CBM_LANG_DLANG. */ +CBMLanguage cbm_disambiguate_d(const char *path); + /* Disambiguate .inc files by reading first 4KB of content. * Returns CBM_LANG_OBJECTSCRIPT_ROUTINE if it looks like an ObjectScript * include (a "ROUTINE " header), otherwise CBM_LANG_BITBAKE. diff --git a/src/discover/language.c b/src/discover/language.c index 116cec024..b5a383137 100644 --- a/src/discover/language.c +++ b/src/discover/language.c @@ -1371,6 +1371,107 @@ CBMLanguage cbm_disambiguate_res(const char *path) { return lang_res_bytes(head, n); } +/* A make rule's targets end at a ':' followed by whitespace or the end of the + * file, which skips drive-letter colons ("C:/", "C:\"). */ +static bool is_rule_colon(const char *p, bool whole_file) { + char next = p[SKIP_ONE]; + return *p == ':' && (next == ' ' || next == '\t' || next == '\r' || next == '\n' || + (next == '\0' && whole_file)); +} + +/* Bytes of D whitespace at p, or 0: space, tab, vertical tab, form feed, CR + * and the UTF-8 line and paragraph separators U+2028/U+2029, all of which split + * D tokens. '\n' ends the line and is the caller's. */ +static size_t d_space_len(const char *p) { + if (*p == ' ' || *p == '\t' || *p == '\v' || *p == '\f' || *p == '\r') { + return SKIP_ONE; + } + if (strncmp(p, "\xE2\x80\xA8", SLEN("\xE2\x80\xA8")) == 0 || + strncmp(p, "\xE2\x80\xA9", SLEN("\xE2\x80\xA9")) == 0) { + return SLEN("\xE2\x80\xA8"); + } + return 0; +} + +/* True if the line at p is a make rule "target...: prereq..." whose targets all + * look like paths (contain '/', '\' or '.'); a backslash-escaped space stays + * inside its target. D source fails this: "public:", "@safe:", "extern(C):" + * and "import a.b : c" all have a non-path word or D punctuation before the + * colon. An attribute ('@', as in "@1.0:") or a comment between tokens + * ("public/+ +/:") is D even with a '.' or '/' before the colon, so a target + * holding either is never dep-info. */ +static bool is_dep_rule_line(const char *p, bool whole_file) { + bool in_target = false; + bool path_like = false; + bool any_target = false; + for (; *p && *p != '\n'; p++) { + if (is_rule_colon(p, whole_file)) { + return in_target ? path_like : any_target; + } + size_t space = d_space_len(p); + if (space) { + if (in_target && !path_like) { + return false; + } + in_target = false; + path_like = false; + p += space - SKIP_ONE; + continue; + } + if (strchr("(){};=\"',@", *p)) { + return false; + } + if (*p == '/' && (p[SKIP_ONE] == '*' || p[SKIP_ONE] == '+' || p[SKIP_ONE] == '/')) { + return false; + } + in_target = true; + any_target = true; + path_like = path_like || *p == '/' || *p == '\\' || *p == '.'; + if (*p == '\\' && p[SKIP_ONE] == ' ') { + p++; /* escaped space: part of this target */ + } + } + return false; +} + +/* Disambiguate .d files: shared by D source and make-style dependency files + * written by rustc/cargo (target//deps/-.d), gcc/clang -MD + * and CMake (.o.d). Those are "target: prereq ..." rules, often tens of + * KB on one line, which the D grammar parses slowly into nothing but a module + * node, so they are reported as unsupported (CBM_LANG_COUNT). Defaults to D on + * any doubt (preserves existing behaviour). `n` is the count of head bytes buf + * was made from. */ +static CBMLanguage lang_d_text(const char *buf, size_t n) { + /* A colon at the end of buf only ends the rule if nothing was cut off. */ + bool whole_file = n < LANG_PROBE_HEAD && strlen(buf) == n; + + const char *p = buf; + if (strncmp(p, "\xEF\xBB\xBF", SLEN("\xEF\xBB\xBF")) == 0) { + p += SLEN("\xEF\xBB\xBF"); + } + while (*p && isspace((unsigned char)*p)) { + p++; + } + /* A shebang or a leading D comment (including the DUB single-file + * "/+ dub.sdl:" recipe) is D; dep-info never starts that way. */ + if (*p == '#' || + (p[0] == '/' && (p[SKIP_ONE] == '/' || p[SKIP_ONE] == '*' || p[SKIP_ONE] == '+'))) { + return CBM_LANG_DLANG; + } + return is_dep_rule_line(p, whole_file) ? CBM_LANG_COUNT : CBM_LANG_DLANG; +} + +CBMLanguage cbm_disambiguate_d(const char *path) { + unsigned char head[LANG_PROBE_HEAD]; + size_t n = 0; + if (!lang_read_head(path, head, sizeof(head), &n)) { + return CBM_LANG_DLANG; + } + char buf[LANG_PROBE_HEAD + SKIP_ONE]; + lang_head_text(buf, LANG_PROBE_HEAD, head, n); + return lang_d_text(buf, n); +} + /* Disambiguate .cls files: shared by InterSystems ObjectScript UDL, Salesforce * Apex and Visual Basic 6 class modules (#721). ObjectScript class files begin * with a line of the form "Class ..."; VB6 class modules @@ -1549,6 +1650,13 @@ static const char *lang_probe_extension(const char *filename) { return (dot && lang_name_in(dot, PROBED)) ? dot : NULL; } +/* .d is shared by D and make-style dep-info (cargo, gcc -MD). Probe only while + * .d maps to D, so a user override to another language wins. */ +static bool lang_probe_d(CBMLanguage lang, const char *filename) { + const char *dot = strrchr(filename, '.'); + return lang == CBM_LANG_DLANG && dot && strcmp(dot, ".d") == 0; +} + /* "\n\n", CBM_LANG_OBJECTSCRIPT_EXPORT, NULL}, {"t.xml", "\n\n", CBM_LANG_XML, NULL}, @@ -1436,6 +1507,7 @@ TEST(lang_classify_unreadable_content_keeps_the_name_default) { ASSERT_EQ(cbm_language_classify("a.cfc", NULL, 0, false, false), CBM_LANG_CFSCRIPT); ASSERT_EQ(cbm_language_classify("a.frm", NULL, 0, false, false), CBM_LANG_FORM); ASSERT_EQ(cbm_language_classify("a.res", NULL, 0, false, false), CBM_LANG_RESCRIPT); + ASSERT_EQ(cbm_language_classify("a.d", NULL, 0, false, false), CBM_LANG_DLANG); ASSERT_EQ(cbm_language_classify("a.xml", NULL, 0, false, false), CBM_LANG_XML); ASSERT_EQ(cbm_language_classify("run-tests", NULL, 0, false, false), CBM_LANG_COUNT); ASSERT_EQ(cbm_language_classify("main.go", NULL, 0, false, false), CBM_LANG_GO); @@ -1662,6 +1734,8 @@ SUITE(language) { RUN_TEST(lang_frm_form_stays_form); RUN_TEST(lang_res_binary_resource_unsupported); RUN_TEST(lang_res_rescript_stays_rescript); + RUN_TEST(lang_d_dep_info_unsupported); + RUN_TEST(lang_d_source_stays_dlang); /* Go test ports */ /* New languages */ diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 5580fc97b..86f72fb12 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -631,6 +631,71 @@ TEST(pipeline_structure_nodes) { PASS(); } +/* Number of `label` nodes whose file_path starts with `prefix`, or -1. */ +static int count_nodes_with_path_prefix(cbm_store_t *s, const char *project, const char *label, + const char *prefix) { + cbm_node_t *nodes = NULL; + int count = 0; + if (cbm_store_find_nodes_by_label(s, project, label, &nodes, &count) != CBM_STORE_OK) { + return -1; + } + int hits = 0; + for (int i = 0; i < count; i++) { + if (nodes[i].file_path && strncmp(nodes[i].file_path, prefix, strlen(prefix)) == 0) { + hits++; + } + } + cbm_store_free_nodes(nodes, count); + return hits; +} + +/* Make-style dep-info .d files are dropped at discovery, so the graph holds no + * File or Module node for them and no Folder for a directory that held only + * dep-info. Real D source beside a dep-info file keeps its nodes and folder. */ +TEST(pipeline_d_dep_info_leaves_no_nodes) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_dotd_graph_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + char repo[512]; + snprintf(repo, sizeof(repo), "%s/repo", tmp); + write_temp_file(repo, "cargo-target/debug/deps/app-0123abcd.d", + "/r/cargo-target/debug/deps/app-0123abcd.d: src/main.rs\n\nsrc/main.rs:\n"); + write_temp_file(repo, "src/app.d", "module app;\n\nvoid main() {}\n"); + write_temp_file(repo, "src/app.o.d", "src/app.o: src/app.d\n"); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/test.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(repo, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + const char *project = cbm_pipeline_project_name(p); + + int dep_folders = count_nodes_with_path_prefix(s, project, "Folder", "cargo-target"); + int dep_files = count_nodes_with_path_prefix(s, project, "File", "cargo-target"); + int dep_modules = count_nodes_with_path_prefix(s, project, "Module", "cargo-target"); + int obj_files = count_nodes_with_path_prefix(s, project, "File", "src/app.o.d"); + int obj_modules = count_nodes_with_path_prefix(s, project, "Module", "src/app.o.d"); + int src_folders = count_nodes_with_path_prefix(s, project, "Folder", "src"); + int src_files = count_nodes_with_path_prefix(s, project, "File", "src/app.d"); + int src_modules = count_nodes_with_path_prefix(s, project, "Module", "src/app.d"); + + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + + ASSERT_EQ(dep_folders, 0); + ASSERT_EQ(dep_files, 0); + ASSERT_EQ(dep_modules, 0); + ASSERT_EQ(obj_files, 0); + ASSERT_EQ(obj_modules, 0); + ASSERT_EQ(src_folders, 1); + ASSERT_EQ(src_files, 1); + ASSERT_EQ(src_modules, 1); + PASS(); +} + /* Issue #516: an ADR stored via manage_adr (project_summaries) must survive a * full re-index. A full re-index deletes the DB and rebuilds it from the graph * buffer, which writes an empty project_summaries table; the fix captures the @@ -19294,6 +19359,7 @@ SUITE(pipeline) { RUN_TEST(pipeline_sql_dump_graph_matches_the_full_parse_issue1735); RUN_TEST(pipeline_spill_resolves_namespace_imports_like_memory); RUN_TEST(pipeline_structure_nodes); + RUN_TEST(pipeline_d_dep_info_leaves_no_nodes); RUN_TEST(pipeline_committed_counts_match_persisted); RUN_TEST(pipeline_adr_survives_full_reindex); RUN_TEST(pipeline_export_error_snapshot_on_artifact_failure);