Skip to content
6 changes: 6 additions & 0 deletions src/discover/discover.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Uppercase>" header), otherwise CBM_LANG_BITBAKE.
Expand Down
116 changes: 116 additions & 0 deletions src/discover/language.c
Original file line number Diff line number Diff line change
Expand Up @@ -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/<profile>/deps/<crate>-<hash>.d), gcc/clang -MD
* and CMake (<object>.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 <UppercasePackage>..."; VB6 class modules
Expand Down Expand Up @@ -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;
}

/* "<Export generator=" in the first line-probe bytes: an ObjectScript Studio
* export, which is XML by name. */
static bool lang_objectscript_export(const unsigned char *head, size_t n) {
Expand All @@ -1571,6 +1679,9 @@ size_t cbm_language_probe_bytes_with(const cbm_userconfig_t *config, const char
if (ext) {
return strcmp(ext, ".cfc") == 0 ? LANG_PROBE_CFC : LANG_PROBE_HEAD;
}
if (lang_probe_d(lang, filename)) {
return LANG_PROBE_HEAD;
}
return lang == CBM_LANG_XML ? LANG_PROBE_LINE : 0;
}

Expand Down Expand Up @@ -1614,6 +1725,11 @@ CBMLanguage cbm_language_classify_with(const cbm_userconfig_t *config, const cha
/* .res: ReScript, or a binary Godot / Windows resource (#2176) */
lang = readable ? lang_res_bytes(head, head_len) : CBM_LANG_RESCRIPT;
}
} else if (lang_probe_d(lang, filename)) {
/* D, or a make-style dep-info file */
char buf[LANG_PROBE_HEAD + SKIP_ONE];
lang_head_text(buf, LANG_PROBE_HEAD, head, head_len);
lang = readable ? lang_d_text(buf, head_len) : CBM_LANG_DLANG;
}
if (lang == CBM_LANG_XML && readable && lang_objectscript_export(head, head_len)) {
return CBM_LANG_OBJECTSCRIPT_EXPORT;
Expand Down
48 changes: 48 additions & 0 deletions tests/test_discover.c
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
#include "test_framework.h"
#include "test_helpers.h"
#include "discover/discover.h"
#include "discover/userconfig.h"
#include "foundation/platform.h"

typedef struct {
Expand Down Expand Up @@ -2272,6 +2273,49 @@ TEST(shebang_oversized_first_line_unindexed) {
PASS();
}

/* ── .d: D source vs make/cargo dep-info ───────────────────────── */

/* rustc/cargo dep-info (and gcc -MD, CMake .o.d) files share .d with D source
* but are Makefile rules the D grammar parses slowly into nothing useful, so
* they must not be indexed as D. */
static const char CARGO_DEP_INFO[] = "/home/u/proj/t/debug/deps/foo-abc123.d: src/lib.rs src/a.rs\n"
"\n"
"src/lib.rs:\n"
"src/a.rs:\n";

TEST(discover_d_dep_info_not_indexed_as_dlang) {
CBMLanguage dep;
CBMLanguage src;
ASSERT(shebang_probe("cbm_disc_dotd", "t/debug/deps/foo-abc123.d", CARGO_DEP_INFO, &dep));
ASSERT(shebang_probe("cbm_disc_dotd", "app.d", "module app;\nimport std.stdio : writeln;\n",
&src));
ASSERT_EQ(dep, CBM_LANG_COUNT);
ASSERT_EQ(src, CBM_LANG_DLANG);
PASS();
}

/* The probe runs only while .d maps to D, so a user override to another
* language wins. */
TEST(discover_d_user_override_skips_dep_info_probe) {
char *cfg_dir = th_mktempdir("cbm_disc_dotd_uc");
ASSERT(cfg_dir != NULL);
ASSERT_EQ(th_write_file(TH_PATH(cfg_dir, ".codebase-memory.json"),
"{\"extra_extensions\":{\".d\":\"makefile\"}}"),
0);
cbm_userconfig_t *cfg = cbm_userconfig_load(cfg_dir);
th_cleanup(cfg_dir);
ASSERT_NOT_NULL(cfg);

cbm_set_user_lang_config(cfg);
CBMLanguage lang;
bool ok = shebang_probe("cbm_disc_dotd_uc", "foo-abc123.d", CARGO_DEP_INFO, &lang);
cbm_set_user_lang_config(NULL);
cbm_userconfig_free(cfg);
ASSERT(ok);
ASSERT_EQ(lang, CBM_LANG_MAKEFILE);
PASS();
}

/* ── Suite ─────────────────────────────────────────────────────── */

SUITE(discover) {
Expand Down Expand Up @@ -2364,6 +2408,10 @@ SUITE(discover) {
RUN_TEST(shebang_embedded_nul_unindexed);
RUN_TEST(shebang_oversized_first_line_unindexed);

/* .d: D source vs make/cargo dep-info */
RUN_TEST(discover_d_dep_info_not_indexed_as_dlang);
RUN_TEST(discover_d_user_override_skips_dep_info_probe);

/* Integration tests (cross-platform) */
RUN_TEST(discover_simple);
RUN_TEST(discover_wide_sibling_fanout_exceeds_initial_walk_stack);
Expand Down
74 changes: 74 additions & 0 deletions tests/test_language.c
Original file line number Diff line number Diff line change
Expand Up @@ -835,6 +835,73 @@ TEST(lang_cfc_default_on_read_fail) {
PASS();
}

/* ── .d: D source vs make/cargo dep-info ───────────────────────── */

/* Write content to a temp .d file and return cbm_disambiguate_d() for it. A
* setup failure returns -1 so neither a D nor a COUNT expectation can pass
* vacuously. */
static int disambiguate_d_content(const char *content) {
char path[256];
snprintf(path, sizeof(path), "%s/test_lang_dotd.d", cbm_tmpdir());
if (!write_probe_file(path, content)) {
return -1;
}
CBMLanguage lang = cbm_disambiguate_d(path);
remove(path);
return (int)lang;
}

TEST(lang_d_dep_info_unsupported) {
/* cargo (absolute target), gcc -MD continuation, CMake object rule, Windows
* drive letter (not the rule colon), escaped space (one target, not "app"),
* and the GNU make manual's "foo.o foo.d : ..." (two targets, spaced colon). */
ASSERT_EQ(disambiguate_d_content("/home/u/t/debug/deps/foo-abc123.d: src/lib.rs src/a.rs\n"
"\nsrc/lib.rs:\n"),
CBM_LANG_COUNT);
ASSERT_EQ(disambiguate_d_content("foo.o: foo.c /usr/include/stdio.h \\\n"
" /usr/include/features.h\n"),
CBM_LANG_COUNT);
ASSERT_EQ(disambiguate_d_content("CMakeFiles/x.dir/a.cpp.o: \\\n /src/a.cpp\n"),
CBM_LANG_COUNT);
ASSERT_EQ(disambiguate_d_content("C:/b/foo.o: C:/s/foo.c \\\r\n C:/s/foo.h\r\n"),
CBM_LANG_COUNT);
ASSERT_EQ(disambiguate_d_content("out/my\\ app: main.c\n"), CBM_LANG_COUNT);
ASSERT_EQ(disambiguate_d_content("foo.o foo.d : foo.c defs.h\n"), CBM_LANG_COUNT);
PASS();
}

TEST(lang_d_source_stays_dlang) {
ASSERT_EQ(disambiguate_d_content("module a;\n"), CBM_LANG_DLANG);
ASSERT_EQ(disambiguate_d_content("import std.stdio : writeln;\n"), CBM_LANG_DLANG);
ASSERT_EQ(disambiguate_d_content("@safe:\nvoid f() {}\n"), CBM_LANG_DLANG);
/* A leading comment is D, even DUB's "/+ dub.sdl:" single-file recipe. */
ASSERT_EQ(disambiguate_d_content("/+ dub.sdl:\n name \"hello\"\n+/\nvoid main() {}\n"),
CBM_LANG_DLANG);
ASSERT_EQ(disambiguate_d_content("enum E : ubyte {\n a,\n}\n"), CBM_LANG_DLANG);
/* A float UDA ("@ FloatLiteral") has a '.' before its colon, alone or glued
* to a keyword. */
ASSERT_EQ(disambiguate_d_content("@1.0:\nvoid f() {}\n"), CBM_LANG_DLANG);
ASSERT_EQ(disambiguate_d_content("nothrow@1.0:\nvoid f() {}\n"), CBM_LANG_DLANG);
/* A comment between tokens puts a '/' before the colon. */
ASSERT_EQ(disambiguate_d_content("public/**/:\nvoid f() {}\n"), CBM_LANG_DLANG);
ASSERT_EQ(disambiguate_d_content("public/+ +/:\nvoid f() {}\n"), CBM_LANG_DLANG);
ASSERT_EQ(disambiguate_d_content("public//note: x\nvoid f() {}\n"), CBM_LANG_DLANG);
/* Every D whitespace splits tokens, not only space, tab and CR: form feed,
* vertical tab, U+2028 and U+2029. */
ASSERT_EQ(disambiguate_d_content("import\fstd.stdio : writeln;\n"), CBM_LANG_DLANG);
ASSERT_EQ(disambiguate_d_content("import\vstd.stdio : writeln;\n"), CBM_LANG_DLANG);
ASSERT_EQ(disambiguate_d_content("import\xE2\x80\xA8"
"std.stdio : writeln;\n"),
CBM_LANG_DLANG);
ASSERT_EQ(disambiguate_d_content("import\xE2\x80\xA9"
"std.stdio : writeln;\n"),
CBM_LANG_DLANG);
/* Default on doubt: empty or unreadable. */
ASSERT_EQ(disambiguate_d_content(""), CBM_LANG_DLANG);
ASSERT_EQ(cbm_disambiguate_d("/tmp/nonexistent_file_12345.d"), CBM_LANG_DLANG);
PASS();
}

/* --- New languages (auto-generated) --- */
TEST(lang_ext_solidity) {
ASSERT_EQ(cbm_language_for_extension(".sol"), CBM_LANG_SOLIDITY);
Expand Down Expand Up @@ -1348,6 +1415,7 @@ TEST(lang_probe_bytes_follow_the_name) {
ASSERT_EQ(cbm_language_probe_bytes("foo.inc"), 4096);
ASSERT_EQ(cbm_language_probe_bytes("Form1.frm"), 4096);
ASSERT_EQ(cbm_language_probe_bytes("App.res"), 4096);
ASSERT_EQ(cbm_language_probe_bytes("app.d"), 4096);
ASSERT_EQ(cbm_language_probe_bytes("Widget.cfc"), 16384);
ASSERT_EQ(cbm_language_probe_bytes("pom.xml"), 255);
/* An unknown name falls back to a shebang probe of the first line. */
Expand Down Expand Up @@ -1389,6 +1457,9 @@ TEST(lang_classify_matches_every_content_rule) {
{"p.frm", "#procedure foo\nLocal F = a;\n", CBM_LANG_FORM, cbm_disambiguate_frm},
{"q.res", "RSRC\0\0\0\1binary", CBM_LANG_COUNT, cbm_disambiguate_res},
{"r.res", "let x = 1\n", CBM_LANG_RESCRIPT, cbm_disambiguate_res},
{"dep.d", "target/debug/deps/app-0a1b2c.d: src/main.rs src/lib.rs\n", CBM_LANG_COUNT,
cbm_disambiguate_d},
{"app.d", "module app;\nimport std.stdio : writeln;\n", CBM_LANG_DLANG, cbm_disambiguate_d},
{"s.xml", "<?xml version=\"1.0\"?>\n<Export generator=\"IRIS\" version=\"26\">\n",
CBM_LANG_OBJECTSCRIPT_EXPORT, NULL},
{"t.xml", "<?xml version=\"1.0\"?>\n<project/>\n", CBM_LANG_XML, NULL},
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 */
Expand Down
Loading
Loading