feat(extraction): add CFML language support (.cfc/.cfm/.cfs) (#1118) (#1153)

Tag-based and bare-script CFML, extends/implements, <cfscript>/<cfquery> delegation, BOM + unquoted-attribute handling. Wasm grammars verified bit-for-bit reproducible from cfmleditor/tree-sitter-cfml. Validated on FW/1, ColdBox, CFWheels. Follow-up: #1152.

Co-authored-by: ghedwards <125586+ghedwards@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-07-02 18:25:53 -05:00
committed by GitHub
co-authored by ghedwards Claude Fable 5
parent cc89146454
commit 816bacb7f2
13 changed files with 952 additions and 3 deletions
+26
View File
@@ -0,0 +1,26 @@
import type { LanguageExtractor } from '../tree-sitter-types';
/**
* `<cfquery>` SQL bodies: `#hash#` expressions inside the SQL text are real
* CFML expressions (tree-sitter-cfml's `cfquery` grammar parses them
* structurally — `call_expression`/`member_expression`, same shape as
* cfscript's), so a call like `#getCurrentUser().getId()#` embedded in a
* WHERE clause is a genuine call edge. The surrounding SQL keywords/
* identifiers aren't symbols CodeGraph models — only `call_expression` is
* mapped, so extraction yields call references and nothing else.
*/
export const cfqueryExtractor: LanguageExtractor = {
functionTypes: [],
classTypes: [],
methodTypes: [],
interfaceTypes: [],
structTypes: [],
enumTypes: [],
typeAliasTypes: [],
importTypes: [],
callTypes: ['call_expression'],
variableTypes: [],
nameField: 'name',
bodyField: 'body',
paramsField: 'parameters',
};
+69
View File
@@ -0,0 +1,69 @@
import type { Node as SyntaxNode } from 'web-tree-sitter';
import { getNodeText, getChildByField } from '../tree-sitter-helpers';
import type { LanguageExtractor } from '../tree-sitter-types';
/** CFML access modifiers (`public`/`private`/`package`/`remote`) on a function_declaration. */
function cfmlVisibility(node: SyntaxNode): 'public' | 'private' | 'protected' | 'internal' | undefined {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child?.type === 'access_type') {
const text = child.text;
if (text === 'public') return 'public';
if (text === 'private') return 'private';
if (text === 'package') return 'internal';
if (text === 'remote') return 'public';
}
}
return undefined;
}
export const cfscriptExtractor: LanguageExtractor = {
functionTypes: ['function_declaration', 'function_expression', 'arrow_function'],
classTypes: ['component'],
// `component` is reused for both `component { ... }` and `interface { ... }` —
// the only difference is the literal first token (verified via the grammar's
// native binding: child(0).type is 'component' or 'interface', both unnamed).
classifyClassNode: (node) => (node.child(0)?.type === 'interface' ? 'interface' : 'class'),
methodTypes: ['function_declaration', 'method_definition'],
interfaceTypes: [],
structTypes: [],
enumTypes: [],
typeAliasTypes: [],
importTypes: ['import_statement', 'include_statement'],
callTypes: ['call_expression'],
variableTypes: ['variable_declaration'],
propertyTypes: ['property_declaration'],
nameField: 'name',
bodyField: 'body',
paramsField: 'parameters',
getVisibility: cfmlVisibility,
getSignature: (node, source) => {
const params = getChildByField(node, 'parameters');
return params ? getNodeText(params, source) : undefined;
},
extractImport: (node, source) => {
const importText = source.substring(node.startIndex, node.endIndex).trim();
if (node.type === 'include_statement') {
// `include "path/to/file.cfm";` — the included template path.
const expr = node.namedChildren.find((c: SyntaxNode) => c.type === 'string');
if (!expr) return null;
const moduleName = getNodeText(expr, source).replace(/^["']|["']$/g, '');
return moduleName ? { moduleName, signature: importText } : null;
}
// `import com.foo.Bar;` (dotted path) or `import "java:java.util.ArrayList";` (string form)
const sourceNode = getChildByField(node, 'source');
if (!sourceNode) return null;
let moduleName: string;
if (sourceNode.type === 'import_path') {
moduleName = sourceNode.namedChildren
.map((c: SyntaxNode) => getNodeText(c, source))
.join('.');
} else {
moduleName = getNodeText(sourceNode, source).replace(/^["']|["']$/g, '');
}
return moduleName ? { moduleName, signature: importText } : null;
},
};
+4
View File
@@ -27,6 +27,8 @@ import { luaExtractor } from './lua';
import { rExtractor } from './r';
import { luauExtractor } from './luau';
import { objcExtractor } from './objc';
import { cfscriptExtractor } from './cfscript';
import { cfqueryExtractor } from './cfquery';
export const EXTRACTORS: Partial<Record<Language, LanguageExtractor>> = {
typescript: typescriptExtractor,
@@ -51,4 +53,6 @@ export const EXTRACTORS: Partial<Record<Language, LanguageExtractor>> = {
r: rExtractor,
luau: luauExtractor,
objc: objcExtractor,
cfscript: cfscriptExtractor,
cfquery: cfqueryExtractor,
};