Skip to content

Commit 31b611a

Browse files
authored
Merge pull request #1872 from DeusData/feat/1412-cfml-distill
feat(lang): CFML tag-dialect .cfc routing + embedded <cfscript> defs (distilled from #1412)
2 parents d706c33 + 229b4fe commit 31b611a

7 files changed

Lines changed: 264 additions & 20 deletions

File tree

‎internal/cbm/extract_imports.c‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1588,8 +1588,18 @@ static void parse_embedded_imports(CBMExtractCtx *ctx) {
15881588
embedded_collect_content_nodes(ctx->root, e, hits, &hit_count, MAX_EMBEDDED_BLOCKS);
15891589
for (int i = 0; i < hit_count; i++) {
15901590
CBMLanguage embedded_language = e->embedded_language;
1591-
bool extract_structure = ctx->language == CBM_LANG_VUE;
1592-
if (extract_structure &&
1591+
/* Structure (defs + calls), not just imports, for hosts whose
1592+
* embedded language carries real code. Vue since #1852; CFML's
1593+
* <cfscript> since the #1412 distillation. */
1594+
bool extract_structure =
1595+
ctx->language == CBM_LANG_VUE || ctx->language == CBM_LANG_CFML;
1596+
/* The attribute resolver is Vue's: it inspects <script lang=/src=>
1597+
* and OVERRIDES the spec's embedded language (JS default, TS on
1598+
* lang="ts", bail on src=). CFML's cf_script_tag carries no such
1599+
* attributes and its embedded language is fixed by the spec row
1600+
* (CFSCRIPT) — running the resolver would silently rewrite it to
1601+
* JavaScript. Resolve only for Vue. */
1602+
if (ctx->language == CBM_LANG_VUE &&
15931603
!vue_embedded_language(ctx, hits[i].script, &embedded_language)) {
15941604
continue;
15951605
}
@@ -3083,6 +3093,9 @@ void cbm_extract_imports(CBMExtractCtx *ctx) {
30833093
case CBM_LANG_SVELTE:
30843094
case CBM_LANG_VUE:
30853095
case CBM_LANG_ASTRO:
3096+
/* Tag-dialect CFML: <cfscript> bodies are opaque cf_script_content to the
3097+
* cfml grammar; the embedded spec re-parses them as CFSCRIPT (#1412). */
3098+
case CBM_LANG_CFML:
30863099
parse_embedded_imports(ctx);
30873100
break;
30883101
default:

‎internal/cbm/lang_specs.c‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -892,6 +892,14 @@ static const char *graphql_field_types[] = {"field_definition", "input_value_def
892892
// the generic embedded-imports walker re-parse that slice with the JS grammar
893893
// so the existing ES import extractor sees real import_statement nodes.
894894
// Terminator: an entry whose script_node_type is NULL.
895+
static const CBMEmbeddedLangSpec cfml_embedded_imports[] = {
896+
/* Tag-dialect CFML keeps <cfscript> bodies as opaque cf_script_content;
897+
* re-parse them with the cfscript grammar through the shared included-
898+
* ranges machinery so defs (and calls) inside legacy components extract
899+
* with absolute coordinates. Distilled from #1412. */
900+
{"cf_script_tag", "cf_script_content", CBM_LANG_CFSCRIPT},
901+
{NULL, NULL, 0},
902+
};
895903
static const CBMEmbeddedLangSpec vue_embedded_imports[] = {
896904
{"script_element", "raw_text", CBM_LANG_JAVASCRIPT},
897905
{NULL, NULL, 0},
@@ -2119,7 +2127,7 @@ static const CBMLangSpec lang_specs[CBM_LANG_COUNT] = {
21192127
[CBM_LANG_CFML] = {CBM_LANG_CFML, cfml_func_types, empty_types, empty_types, cfml_module_types,
21202128
cfml_call_types, empty_types, empty_types, cfml_branch_types, empty_types,
21212129
empty_types, empty_types, NULL, empty_types, NULL, NULL, tree_sitter_cfml,
2122-
NULL},
2130+
cfml_embedded_imports},
21232131

21242132
// CBM_LANG_GLEAM
21252133
[CBM_LANG_GLEAM] = {CBM_LANG_GLEAM, gleam_func_types, gleam_class_types, gleam_field_types,

‎src/discover/discover.c‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -708,6 +708,10 @@ static CBMLanguage detect_file_language(const char *entry_name, const char *abs_
708708
if (dot && strcmp(dot, ".inc") == 0) {
709709
lang = cbm_disambiguate_inc(abs_path);
710710
}
711+
/* Special: .cfc components may be script-dialect or tag-dialect (<cfcomponent>) */
712+
if (dot && strcmp(dot, ".cfc") == 0) {
713+
lang = cbm_disambiguate_cfc(abs_path);
714+
}
711715
/* Special: ObjectScript Studio Export XML (<Export generator="...">) is
712716
* detected by content; otherwise .xml stays XML. */
713717
if (lang == CBM_LANG_XML) {

‎src/discover/discover.h‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,12 @@ CBMLanguage cbm_disambiguate_inc(const char *path);
6464
* shebang, an embedded NUL in the first line, or an unknown interpreter. */
6565
CBMLanguage cbm_language_from_shebang(const char *path);
6666

67+
/* Disambiguate .cfc files by reading the head of the content.
68+
* Returns CBM_LANG_CFML if the component is tag-based (a "<cfcomponent" tag, or a
69+
* leading '<'), otherwise CBM_LANG_CFSCRIPT for script-dialect components.
70+
* On read failure, defaults to CBM_LANG_CFSCRIPT. */
71+
CBMLanguage cbm_disambiguate_cfc(const char *path);
72+
6773
/* ── Gitignore pattern matching ──────────────────────────────────── */
6874

6975
typedef struct cbm_gitignore cbm_gitignore_t;

‎src/discover/language.c‎

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
#include "cbm.h" // CBMLanguage, CBM_LANG_*
1212

1313
#include "foundation/constants.h"
14+
#include "foundation/compat.h" // cbm_strcasestr
1415
#include "foundation/compat_fs.h"
1516

1617
enum { LANG_SCAN_PASSES = 2 };
@@ -469,7 +470,10 @@ static const ext_entry_t EXT_TABLE[] = {
469470
/* Qt QML */
470471
{".qml", CBM_LANG_QML},
471472

472-
/* CFML / ColdFusion — .cfc components are script-dialect; .cfm are tag templates */
473+
/* CFML / ColdFusion — .cfm are tag templates; .cfc components may be EITHER
474+
* script-dialect (component { ... }) or tag-dialect (<cfcomponent> ...). The
475+
* table default is script; tag-based .cfc are resolved by content in
476+
* cbm_disambiguate_cfc(). */
473477
{".cfc", CBM_LANG_CFSCRIPT},
474478
{".cfm", CBM_LANG_CFML},
475479

@@ -1306,3 +1310,74 @@ CBMLanguage cbm_disambiguate_inc(const char *path) {
13061310
}
13071311
return CBM_LANG_BITBAKE;
13081312
}
1313+
1314+
/* Case-insensitive prefix match (portable — no strncasecmp dependency). */
1315+
static bool starts_with_ci(const char *s, const char *prefix) {
1316+
for (; *prefix; s++, prefix++) {
1317+
if (tolower((unsigned char)*s) != tolower((unsigned char)*prefix)) {
1318+
return false;
1319+
}
1320+
}
1321+
return true;
1322+
}
1323+
1324+
/* Disambiguate .cfc files: a ColdFusion component may be written in the script
1325+
* dialect ("component { ... }", parsed by the JS-like cfscript grammar) or the
1326+
* tag dialect ("<cfcomponent> ... <cffunction>", parsed by the HTML-derived cfml
1327+
* grammar). The extension table defaults to cfscript because that is what modern
1328+
* Lucee/ACF templates use, but large legacy codebases are predominantly tag-based
1329+
* and feeding those to the wrong grammar fails wholesale. Routing rules:
1330+
* 1. A "<cfcomponent" or top-level "<cffunction" tag ⇒ tag dialect. (The latter
1331+
* catches "bare" tag components that omit the <cfcomponent> wrapper.) This
1332+
* wins regardless of any leading <!---/<cfscript>, so it is checked first.
1333+
* 2. Otherwise the file is script-dialect content. Find the first significant
1334+
* token, skipping whitespace and <!--- ---> comments:
1335+
* - a leading "<cfscript>" wrapper is still script content ⇒ cfscript;
1336+
* - a different leading tag (e.g. <cfquery> in a bare-tag file) ⇒ cfml;
1337+
* - anything else ("component { ... }") ⇒ cfscript.
1338+
* Defaults to CBM_LANG_CFSCRIPT on any doubt (preserves table behaviour). */
1339+
CBMLanguage cbm_disambiguate_cfc(const char *path) {
1340+
if (!path) {
1341+
return CBM_LANG_CFSCRIPT;
1342+
}
1343+
1344+
FILE *f = cbm_fopen(path, "r");
1345+
if (!f) {
1346+
return CBM_LANG_CFSCRIPT;
1347+
}
1348+
1349+
/* Read a generous head: tag components can carry a large license/revision
1350+
* comment block before the <cfcomponent> opener. */
1351+
char buf[CBM_SZ_16K + SKIP_ONE];
1352+
size_t n = fread(buf, SKIP_ONE, CBM_SZ_16K, f);
1353+
buf[n] = '\0';
1354+
(void)fclose(f);
1355+
1356+
/* Rule 1: explicit tag-component markers ⇒ tag dialect. */
1357+
if (cbm_strcasestr(buf, "<cfcomponent") != NULL || cbm_strcasestr(buf, "<cffunction") != NULL) {
1358+
return CBM_LANG_CFML;
1359+
}
1360+
1361+
/* Rule 2: locate the first significant token, past whitespace and comments. */
1362+
const char *p = buf;
1363+
for (;;) {
1364+
while (*p && isspace((unsigned char)*p)) {
1365+
p++;
1366+
}
1367+
if (starts_with_ci(p, "<!---")) {
1368+
const char *end = strstr(p + SLEN("<!---"), "--->");
1369+
if (!end) {
1370+
break; /* comment runs past the buffer — treat as no token */
1371+
}
1372+
p = end + SLEN("--->");
1373+
continue;
1374+
}
1375+
break;
1376+
}
1377+
if (*p == '<') {
1378+
/* A leading <cfscript> wrapper is script content; any other leading tag
1379+
* (bare-tag file) is tag content. */
1380+
return starts_with_ci(p, "<cfscript") ? CBM_LANG_CFSCRIPT : CBM_LANG_CFML;
1381+
}
1382+
return CBM_LANG_CFSCRIPT;
1383+
}

‎tests/test_extraction.c‎

Lines changed: 60 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,52 @@ TEST(extract_cfml_tag_issue38) {
372372
PASS();
373373
}
374374

375+
/* --- CFML tag dialect: script functions inside a <cfscript> block of a tag
376+
* component. The HTML-derived cfml grammar keeps the <cfscript> body as an
377+
* opaque cf_script_content token, so these functions only surface once the
378+
* block is re-parsed with the cfscript grammar through the shared
379+
* included-ranges machinery (parse_one_embedded_block, #1852).
380+
* Also asserts the block-relative line numbers are remapped back to host-file
381+
* lines, and that a leading <cfsetting> void tag (which cascades ERROR nodes in
382+
* the cfml grammar) does not prevent recovery of the functions. --- */
383+
TEST(extract_cfml_embedded_cfscript_defs) {
384+
/* Source layout (1-based lines): 1 <cfcomponent>, 2 <cfsetting>, 3 <cfscript>,
385+
* 4 greet(), 7 addTwo(), 10 </cfscript>, 11 <cffunction tagPing>, 14 close. */
386+
CBMFileResult *r = extract("<cfcomponent>\n"
387+
"<cfsetting requesttimeout=\"10\">\n"
388+
"<cfscript>\n"
389+
" public string function greet(string who) {\n"
390+
" return \"hi \" & who;\n"
391+
" }\n"
392+
" private numeric function addTwo(numeric a) {\n"
393+
" return a + 2;\n"
394+
" }\n"
395+
"</cfscript>\n"
396+
"<cffunction name=\"tagPing\" returntype=\"string\">\n"
397+
" <cfreturn \"pong\">\n"
398+
"</cffunction>\n"
399+
"</cfcomponent>\n",
400+
CBM_LANG_CFML, "app", "Service.cfc");
401+
ASSERT_NOT_NULL(r);
402+
/* Script functions inside <cfscript> are recovered ... */
403+
ASSERT(has_def(r, "Function", "greet"));
404+
ASSERT(has_def(r, "Function", "addTwo"));
405+
/* ... alongside the tag-dialect <cffunction> in the same component. */
406+
ASSERT(has_def(r, "Function", "tagPing"));
407+
/* Line numbers are remapped from block-relative back to host-file lines. */
408+
for (int i = 0; i < r->defs.count; i++) {
409+
const CBMDefinition *d = &r->defs.items[i];
410+
if (strcmp(d->label, "Function") == 0 && strcmp(d->name, "greet") == 0) {
411+
ASSERT(d->start_line == 4);
412+
}
413+
if (strcmp(d->label, "Function") == 0 && strcmp(d->name, "addTwo") == 0) {
414+
ASSERT(d->start_line == 7);
415+
}
416+
}
417+
cbm_free_result(r);
418+
PASS();
419+
}
420+
375421
/* --- Helm / Go template: named templates + include calls (#338) --- */
376422
TEST(extract_helm_templates_issue338) {
377423
CBMFileResult *r = extract("{{- define \"chart.fullname\" -}}\n"
@@ -1689,14 +1735,13 @@ TEST(cpp_function) {
16891735
* node when multiple tests share a file. Each must mint a distinct Function
16901736
* node whose name encodes the suite and case arguments. */
16911737
TEST(cpp_gtest_same_name_collision_issue1266) {
1692-
CBMFileResult *r = extract(
1693-
"namespace demo { int assembleWidget(int s) { return s * 2; } }\n"
1694-
"TEST(WidgetSuite, DoublesSmallSize) { demo::assembleWidget(1); }\n"
1695-
"TEST(WidgetSuite, DoublesZero) { demo::assembleWidget(0); }\n"
1696-
"TEST(WidgetSuite, DoublesLargeSize) {\n"
1697-
" demo::assembleWidget(1000);\n"
1698-
"}\n",
1699-
CBM_LANG_CPP, "t", "direct_test.cpp");
1738+
CBMFileResult *r = extract("namespace demo { int assembleWidget(int s) { return s * 2; } }\n"
1739+
"TEST(WidgetSuite, DoublesSmallSize) { demo::assembleWidget(1); }\n"
1740+
"TEST(WidgetSuite, DoublesZero) { demo::assembleWidget(0); }\n"
1741+
"TEST(WidgetSuite, DoublesLargeSize) {\n"
1742+
" demo::assembleWidget(1000);\n"
1743+
"}\n",
1744+
CBM_LANG_CPP, "t", "direct_test.cpp");
17001745
ASSERT_NOT_NULL(r);
17011746
ASSERT(has_def(r, "Function", "TEST_WidgetSuite_DoublesSmallSize"));
17021747
ASSERT(has_def(r, "Function", "TEST_WidgetSuite_DoublesZero"));
@@ -1708,10 +1753,9 @@ TEST(cpp_gtest_same_name_collision_issue1266) {
17081753

17091754
/* #1266: TEST_F fixture macro also produces unique names. */
17101755
TEST(cpp_gtest_f_unique_name_issue1266) {
1711-
CBMFileResult *r = extract(
1712-
"TEST_F(MyFixture, FirstTest) { doStuff(); }\n"
1713-
"TEST_F(MyFixture, SecondTest) { doOtherStuff(); }\n",
1714-
CBM_LANG_CPP, "t", "fixture_test.cpp");
1756+
CBMFileResult *r = extract("TEST_F(MyFixture, FirstTest) { doStuff(); }\n"
1757+
"TEST_F(MyFixture, SecondTest) { doOtherStuff(); }\n",
1758+
CBM_LANG_CPP, "t", "fixture_test.cpp");
17151759
ASSERT_NOT_NULL(r);
17161760
ASSERT(has_def(r, "Function", "TEST_F_MyFixture_FirstTest"));
17171761
ASSERT(has_def(r, "Function", "TEST_F_MyFixture_SecondTest"));
@@ -3676,9 +3720,9 @@ TEST(extract_ts_decorators_survive_interleaved_comment) {
36763720
ASSERT_FALSE(r->has_error);
36773721
const CBMDefinition *m = find_def_by_name(r, "login");
36783722
ASSERT_NOT_NULL(m);
3679-
ASSERT(decorators_contain(m, "Throttle")); /* below the comment — always worked */
3680-
ASSERT(decorators_contain(m, "HttpCode")); /* above the comment — was dropped */
3681-
ASSERT(decorators_contain(m, "Post")); /* above the comment — was dropped */
3723+
ASSERT(decorators_contain(m, "Throttle")); /* below the comment — always worked */
3724+
ASSERT(decorators_contain(m, "HttpCode")); /* above the comment — was dropped */
3725+
ASSERT(decorators_contain(m, "Post")); /* above the comment — was dropped */
36823726
cbm_free_result(r);
36833727
PASS();
36843728
}
@@ -3937,7 +3981,6 @@ TEST(extract_go_binary_concat_url_no_literal_suffix_issue1249) {
39373981
PASS();
39383982
}
39393983

3940-
39413984
/* Reproduce-first: Java module QN must derive from the CONTAINING DIRECTORY, not
39423985
* the filename stem, so a top-level class `Outer` in `Outer.java` is `t.Outer`,
39433986
* NOT the doubled `t.Outer.Outer`. The nested method def QN must also equal the
@@ -6037,6 +6080,7 @@ SUITE(extraction) {
60376080
RUN_TEST(extract_qml_issue42);
60386081
RUN_TEST(extract_cfscript_issue38);
60396082
RUN_TEST(extract_cfml_tag_issue38);
6083+
RUN_TEST(extract_cfml_embedded_cfscript_defs);
60406084
RUN_TEST(extract_helm_templates_issue338);
60416085
RUN_TEST(extract_helm_values_toplevel_issue338);
60426086

‎tests/test_language.c‎

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -609,6 +609,92 @@ TEST(lang_m_default_on_read_fail) {
609609
PASS();
610610
}
611611

612+
/* ── .cfc disambiguation (tag vs script dialect) ───────────────── */
613+
614+
/* Helper: write content to a temp .cfc and return its disambiguated language. */
615+
static CBMLanguage disambiguate_cfc_content(const char *name, const char *content) {
616+
char path[256];
617+
snprintf(path, sizeof(path), "%s/%s", cbm_tmpdir(), name);
618+
FILE *f = fopen(path, "w");
619+
if (!f) {
620+
return CBM_LANG_COUNT;
621+
}
622+
fputs(content, f);
623+
fclose(f);
624+
CBMLanguage lang = cbm_disambiguate_cfc(path);
625+
remove(path);
626+
return lang;
627+
}
628+
629+
TEST(lang_cfc_tag_component) {
630+
/* <cfcomponent> wrapper ⇒ tag dialect. */
631+
ASSERT_EQ(disambiguate_cfc_content("test_cfc_tag.cfc",
632+
"<cfcomponent>\n<cffunction name=\"f\"></cffunction>\n"
633+
"</cfcomponent>\n"),
634+
CBM_LANG_CFML);
635+
PASS();
636+
}
637+
638+
TEST(lang_cfc_bare_cffunction) {
639+
/* A component that omits <cfcomponent> but uses <cffunction> is still tag. */
640+
ASSERT_EQ(disambiguate_cfc_content("test_cfc_bare.cfc",
641+
"<cffunction name=\"f\" output=\"false\">\n"
642+
"<cfreturn 1>\n</cffunction>\n"),
643+
CBM_LANG_CFML);
644+
PASS();
645+
}
646+
647+
TEST(lang_cfc_script_component) {
648+
/* Plain "component { ... }" ⇒ script dialect. */
649+
ASSERT_EQ(disambiguate_cfc_content("test_cfc_script.cfc",
650+
"component {\n function f() { return 1; }\n}\n"),
651+
CBM_LANG_CFSCRIPT);
652+
PASS();
653+
}
654+
655+
TEST(lang_cfc_script_bare_keyword) {
656+
/* "component" on its own line (brace on the next) ⇒ script dialect. */
657+
ASSERT_EQ(disambiguate_cfc_content("test_cfc_kw.cfc",
658+
"component\n{\n function f() { return 1; }\n}\n"),
659+
CBM_LANG_CFSCRIPT);
660+
PASS();
661+
}
662+
663+
TEST(lang_cfc_cfscript_wrapped_script) {
664+
/* A script component wrapped in a leading <cfscript> is still script — the
665+
* leading '<' must NOT route it to the tag grammar. */
666+
ASSERT_EQ(
667+
disambiguate_cfc_content("test_cfc_wrapped.cfc",
668+
"<cfscript>\ncomponent {\n function f() { return 1; }\n}\n"
669+
"</cfscript>\n"),
670+
CBM_LANG_CFSCRIPT);
671+
PASS();
672+
}
673+
674+
TEST(lang_cfc_tag_after_license_comment) {
675+
/* A leading <!--- ---> license comment before <cfcomponent> ⇒ tag. */
676+
ASSERT_EQ(disambiguate_cfc_content("test_cfc_licensed.cfc",
677+
"<!---\n Copyright\n--->\n<cfcomponent>\n</cfcomponent>\n"),
678+
CBM_LANG_CFML);
679+
PASS();
680+
}
681+
682+
TEST(lang_cfc_script_after_license_comment) {
683+
/* A leading <!--- ---> comment before a script component ⇒ script (the
684+
* comment's '<' must be skipped, not treated as a tag opener). */
685+
ASSERT_EQ(disambiguate_cfc_content("test_cfc_lic_script.cfc",
686+
"<!--- header ---> \ncomponent {\n"
687+
" function f() { return 1; }\n}\n"),
688+
CBM_LANG_CFSCRIPT);
689+
PASS();
690+
}
691+
692+
TEST(lang_cfc_default_on_read_fail) {
693+
/* Non-existent file defaults to script dialect. */
694+
ASSERT_EQ(cbm_disambiguate_cfc("/tmp/nonexistent_file_98765.cfc"), CBM_LANG_CFSCRIPT);
695+
PASS();
696+
}
697+
612698
/* --- New languages (auto-generated) --- */
613699
TEST(lang_ext_solidity) {
614700
ASSERT_EQ(cbm_language_for_extension(".sol"), CBM_LANG_SOLIDITY);
@@ -1232,6 +1318,14 @@ SUITE(language) {
12321318
RUN_TEST(lang_m_magma);
12331319
RUN_TEST(lang_m_matlab);
12341320
RUN_TEST(lang_m_default_on_read_fail);
1321+
RUN_TEST(lang_cfc_tag_component);
1322+
RUN_TEST(lang_cfc_bare_cffunction);
1323+
RUN_TEST(lang_cfc_script_component);
1324+
RUN_TEST(lang_cfc_script_bare_keyword);
1325+
RUN_TEST(lang_cfc_cfscript_wrapped_script);
1326+
RUN_TEST(lang_cfc_tag_after_license_comment);
1327+
RUN_TEST(lang_cfc_script_after_license_comment);
1328+
RUN_TEST(lang_cfc_default_on_read_fail);
12351329

12361330
/* Go test ports */
12371331
/* New languages */

0 commit comments

Comments
 (0)