cfml-extractor.ts 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508
  1. import type { Node as SyntaxNode } from 'web-tree-sitter';
  2. import { Node, Edge, ExtractionResult, ExtractionError, UnresolvedReference, Language } from '../types';
  3. import { generateNodeId } from './tree-sitter-helpers';
  4. import { TreeSitterExtractor } from './tree-sitter';
  5. import { getParser } from './grammars';
  6. /**
  7. * CfmlExtractor - Extracts code relationships from CFML source (.cfc/.cfm).
  8. *
  9. * tree-sitter-cfml splits CFML into two related grammars: `cfml` (tag-based —
  10. * `<cfcomponent>`/`<cffunction>`/HTML) and `cfscript` (modern bare-script
  11. * `component { ... }` syntax). The `cfml` grammar's own injections.scm treats
  12. * bare-script content as an opaque blob meant to be re-parsed by `cfscript` —
  13. * that re-parsing only happens at the editor/highlighting layer, not in the
  14. * raw AST, so this extractor replicates it: a file whose first real token
  15. * isn't `<` is delegated wholesale to the cfscript grammar (the dominant
  16. * modern style); otherwise the file is walked tag-by-tag with the cfml
  17. * grammar, delegating any `<cfscript>` tag bodies the same way.
  18. */
  19. export class CfmlExtractor {
  20. private filePath: string;
  21. private source: string;
  22. private language: Language;
  23. private nodes: Node[] = [];
  24. private edges: Edge[] = [];
  25. private unresolvedReferences: UnresolvedReference[] = [];
  26. private errors: ExtractionError[] = [];
  27. /** `language` is the file's detected language — `'cfml'` for `.cfc`/`.cfm`, `'cfscript'` for `.cfs`. Both dialect-switch internally; this only controls the language tag stamped onto emitted nodes/refs. */
  28. constructor(filePath: string, source: string, language: Language = 'cfml') {
  29. this.filePath = filePath;
  30. this.source = source;
  31. this.language = language;
  32. }
  33. extract(): ExtractionResult {
  34. const startTime = Date.now();
  35. try {
  36. if (isBareScriptCfml(this.source)) {
  37. this.extractBareScript();
  38. } else {
  39. this.extractTagBased();
  40. }
  41. } catch (error) {
  42. this.errors.push({
  43. message: `CFML extraction error: ${error instanceof Error ? error.message : String(error)}`,
  44. severity: 'error',
  45. code: 'parse_error',
  46. });
  47. }
  48. return {
  49. nodes: this.nodes,
  50. edges: this.edges,
  51. unresolvedReferences: this.unresolvedReferences,
  52. errors: this.errors,
  53. durationMs: Date.now() - startTime,
  54. };
  55. }
  56. /** Modern bare-script `.cfc`/`.cfm`: delegate the whole file to the cfscript grammar. */
  57. private extractBareScript(): void {
  58. const extractor = new TreeSitterExtractor(this.filePath, this.source, 'cfscript');
  59. const result = extractor.extract();
  60. // cfscript's `component`/`interface` node has no `name` field — a CFC's
  61. // component name is always implicit from its file name, never declared
  62. // in source — so the generic extractor names it '<anonymous>'.
  63. const componentName = this.componentNameFromPath();
  64. for (const node of result.nodes) {
  65. node.language = this.language;
  66. if (node.name === '<anonymous>' && (node.kind === 'class' || node.kind === 'interface')) {
  67. node.name = componentName;
  68. node.qualifiedName = `${this.filePath}::${componentName}`;
  69. } else if (node.qualifiedName === '<anonymous>' || node.qualifiedName.startsWith('<anonymous>::')) {
  70. // Members were scoped under the anonymous component (`<anonymous>::save`)
  71. // — carry the rename into their scope chains so type-validated method
  72. // resolution (which wants `UserService::save`, see resolveMethodOnType)
  73. // can match them. Inner genuinely-anonymous segments are untouched.
  74. node.qualifiedName = componentName + node.qualifiedName.slice('<anonymous>'.length);
  75. }
  76. this.nodes.push(node);
  77. }
  78. this.edges.push(...result.edges);
  79. for (const ref of result.unresolvedReferences) {
  80. ref.language = this.language;
  81. this.unresolvedReferences.push(ref);
  82. }
  83. this.errors.push(...result.errors);
  84. }
  85. /** Legacy tag-based CFML: walk `<cfcomponent>`/`<cffunction>`, delegating `<cfscript>` bodies. */
  86. private extractTagBased(): void {
  87. const parser = getParser('cfml');
  88. if (!parser) {
  89. this.errors.push({
  90. message: 'cfml grammar not loaded',
  91. severity: 'error',
  92. code: 'unsupported_language',
  93. });
  94. return;
  95. }
  96. const tree = parser.parse(this.source);
  97. if (!tree) {
  98. this.errors.push({
  99. message: 'Failed to parse CFML source',
  100. severity: 'error',
  101. code: 'parse_error',
  102. });
  103. return;
  104. }
  105. const fileNode = this.createFileNode();
  106. this.walkProgram(tree.rootNode, fileNode.id);
  107. }
  108. /** Build the file's own `kind:'file'` node, spanning the whole source. Tag-based files need this explicitly — unlike `extractBareScript` (which delegates the whole file to `TreeSitterExtractor` and inherits its file node), `extractTagBased` walks the tree itself and has no other source of one. */
  109. private createFileNode(): Node {
  110. const lines = this.source.split('\n');
  111. const id = generateNodeId(this.filePath, 'file', this.filePath, 1);
  112. const fileNode: Node = {
  113. id,
  114. kind: 'file',
  115. name: this.filePath.split(/[/\\]/).pop() || this.filePath,
  116. qualifiedName: this.filePath,
  117. filePath: this.filePath,
  118. language: this.language,
  119. startLine: 1,
  120. endLine: lines.length,
  121. startColumn: 0,
  122. endColumn: lines[lines.length - 1]?.length || 0,
  123. updatedAt: Date.now(),
  124. };
  125. this.nodes.push(fileNode);
  126. return fileNode;
  127. }
  128. /**
  129. * Walks `program`'s named children with a single forward cursor (not an
  130. * index loop) — `extractComponent` consumes a variable run of FOLLOWING
  131. * siblings as the component body (see its doc comment), so this must
  132. * resume from whatever it last consumed rather than revisiting those same
  133. * cffunction/cfscript siblings a second time as bogus top-level symbols.
  134. */
  135. private walkProgram(root: SyntaxNode, fileNodeId: string): void {
  136. let child: SyntaxNode | null = root.namedChild(0);
  137. while (child) {
  138. if (child.type === 'cf_component_open_tag') {
  139. child = this.extractComponent(child, fileNodeId).nextSibling;
  140. continue;
  141. } else if (child.type === 'cf_function_tag') {
  142. // A cffunction outside any cfcomponent wrapper (rare, but legal in a
  143. // .cfm template) — extract as a top-level function, contained by the file.
  144. this.extractFunctionTag(child, undefined, fileNodeId);
  145. } else if (child.type === 'cf_script_tag') {
  146. this.delegateScriptTag(child, fileNodeId);
  147. } else if (child.type === 'cf_query_tag') {
  148. this.delegateQueryTag(child, fileNodeId);
  149. } else {
  150. this.delegateNestedTags(child, fileNodeId);
  151. }
  152. child = child.nextSibling;
  153. }
  154. }
  155. /**
  156. * `<cfcomponent extends="Base" implements="IFoo,IBar">...</cfcomponent>`.
  157. * The grammar's implicit-end-tag scanner means component body content
  158. * (cffunction tags, cfscript tags, etc.) appears as the open tag's FOLLOWING
  159. * siblings in `program`, not nested children — walk forward to the matching
  160. * cf_component_close_tag.
  161. */
  162. private extractComponent(openTag: SyntaxNode, containerId: string | undefined): SyntaxNode {
  163. const name = this.tagAttr(openTag, 'name') ?? this.componentNameFromPath();
  164. const id = generateNodeId(this.filePath, 'class', name, openTag.startPosition.row + 1);
  165. const classNode: Node = {
  166. id,
  167. kind: 'class',
  168. name,
  169. qualifiedName: `${this.filePath}::${name}`,
  170. filePath: this.filePath,
  171. language: this.language,
  172. startLine: openTag.startPosition.row + 1,
  173. endLine: openTag.startPosition.row + 1, // extended below once the close tag is found
  174. startColumn: openTag.startPosition.column,
  175. endColumn: openTag.endPosition.column,
  176. isExported: true,
  177. updatedAt: Date.now(),
  178. };
  179. this.nodes.push(classNode);
  180. if (containerId) {
  181. this.edges.push({ source: containerId, target: classNode.id, kind: 'contains' });
  182. }
  183. const extendsName = this.tagAttr(openTag, 'extends');
  184. if (extendsName) {
  185. this.unresolvedReferences.push({
  186. fromNodeId: classNode.id,
  187. referenceName: extendsName,
  188. referenceKind: 'extends',
  189. filePath: this.filePath,
  190. line: openTag.startPosition.row + 1,
  191. column: openTag.startPosition.column,
  192. language: this.language,
  193. });
  194. }
  195. const implementsAttr = this.tagAttr(openTag, 'implements');
  196. if (implementsAttr) {
  197. for (const iface of implementsAttr.split(',').map((s) => s.trim()).filter(Boolean)) {
  198. this.unresolvedReferences.push({
  199. fromNodeId: classNode.id,
  200. referenceName: iface,
  201. referenceKind: 'implements',
  202. filePath: this.filePath,
  203. line: openTag.startPosition.row + 1,
  204. column: openTag.startPosition.column,
  205. language: this.language,
  206. });
  207. }
  208. }
  209. // Walk siblings between the open tag and its close tag.
  210. let sibling = openTag.nextSibling;
  211. let lastNode: SyntaxNode = openTag;
  212. while (sibling) {
  213. if (sibling.type === 'cf_component_close_tag') {
  214. lastNode = sibling;
  215. break;
  216. }
  217. if (sibling.type === 'cf_function_tag') {
  218. this.extractFunctionTag(sibling, classNode.id, classNode.id, classNode.name);
  219. } else if (sibling.type === 'cf_script_tag') {
  220. this.delegateScriptTag(sibling, classNode.id, classNode.name);
  221. } else if (sibling.type === 'cf_query_tag') {
  222. this.delegateQueryTag(sibling, classNode.id);
  223. } else {
  224. this.delegateNestedTags(sibling, classNode.id, classNode.name);
  225. }
  226. lastNode = sibling;
  227. sibling = sibling.nextSibling;
  228. }
  229. classNode.endLine = lastNode.endPosition.row + 1;
  230. return lastNode;
  231. }
  232. /**
  233. * `<cffunction name="..." access="..." returntype="...">...</cffunction>`.
  234. * `parentClassId` decides `method` vs top-level `function`; `containerId` is
  235. * the `contains`-edge target (the class when inside one, otherwise the file
  236. * node for a bare top-level cffunction) — kept separate so a top-level
  237. * function still gets a containment edge without being misclassified as a
  238. * method of the file. A method's qualifiedName is scoped under
  239. * `parentClassName` (`TagService::save`, the same `Class::member` shape the
  240. * generic extractor produces) so type-validated method resolution can match.
  241. */
  242. private extractFunctionTag(tag: SyntaxNode, parentClassId: string | undefined, containerId: string | undefined, parentClassName?: string): void {
  243. const name = this.tagAttr(tag, 'name');
  244. if (!name) return;
  245. const kind = parentClassId ? 'method' : 'function';
  246. const id = generateNodeId(this.filePath, kind, name, tag.startPosition.row + 1);
  247. const access = this.tagAttr(tag, 'access');
  248. const visibility = access === 'private' ? 'private'
  249. : access === 'package' ? 'internal'
  250. : access ? 'public'
  251. : undefined;
  252. const fnNode: Node = {
  253. id,
  254. kind,
  255. name,
  256. qualifiedName: parentClassName ? `${parentClassName}::${name}` : `${this.filePath}::${name}`,
  257. filePath: this.filePath,
  258. language: this.language,
  259. startLine: tag.startPosition.row + 1,
  260. endLine: tag.endPosition.row + 1,
  261. startColumn: tag.startPosition.column,
  262. endColumn: tag.endPosition.column,
  263. visibility,
  264. returnType: this.tagAttr(tag, 'returntype'),
  265. updatedAt: Date.now(),
  266. };
  267. this.nodes.push(fnNode);
  268. if (containerId) {
  269. this.edges.push({ source: containerId, target: fnNode.id, kind: 'contains' });
  270. }
  271. // Delegate any <cfscript>/<cfquery> bodies nested inside this function, at
  272. // any depth (e.g. inside <cfif>/<cfloop>/<cftry> control-flow tags).
  273. this.delegateNestedTags(tag, fnNode.id);
  274. }
  275. /**
  276. * Recursively delegates any `cf_script_tag`/`cf_query_tag` found within
  277. * `node`'s subtree — e.g. a `<cfscript>`/`<cfquery>` nested inside
  278. * `<cfif>`/`<cfloop>`/`<cftry>` control-flow tags, which (unlike
  279. * `<cfcomponent>`'s body — see the implicit-end-tag note on `extractComponent`)
  280. * ARE normal children, just possibly several levels deep, so a direct-children
  281. * check misses them. Does not descend into a nested `cf_function_tag` — that
  282. * has its own scope and is walked separately. `parentClassName` rides along
  283. * so a `<cfscript>` at component scope classifies its functions as methods
  284. * scoped under the component.
  285. */
  286. private delegateNestedTags(node: SyntaxNode, containerId: string | undefined, parentClassName?: string): void {
  287. for (let i = 0; i < node.namedChildCount; i++) {
  288. const child = node.namedChild(i);
  289. if (!child) continue;
  290. if (child.type === 'cf_script_tag') {
  291. this.delegateScriptTag(child, containerId, parentClassName);
  292. } else if (child.type === 'cf_query_tag') {
  293. this.delegateQueryTag(child, containerId);
  294. } else if (child.type === 'cf_function_tag') {
  295. continue;
  296. } else {
  297. this.delegateNestedTags(child, containerId, parentClassName);
  298. }
  299. }
  300. }
  301. /**
  302. * Delegate a `<cfscript>...</cfscript>` tag body to the cfscript grammar.
  303. * With `parentClassName` set (the block sits at component scope), functions
  304. * declared at the script's top level are the component's methods
  305. * (`<cfcomponent><cfscript>function configure(){}` — the standard ColdBox
  306. * ModuleConfig shape): they're re-kinded `function` → `method`, and every
  307. * merged symbol's qualifiedName is prefixed with the component scope
  308. * (`configure` → `ModuleConfig::configure`) so type-validated method
  309. * resolution can match them. Functions nested inside another function
  310. * (closures) keep kind `function`.
  311. */
  312. private delegateScriptTag(scriptTag: SyntaxNode, parentId: string | undefined, parentClassName?: string): void {
  313. const content = scriptTag.namedChildren.find((c: SyntaxNode) => c.type === 'cf_script_content');
  314. if (!content) return;
  315. const inner = this.source.substring(content.startIndex, content.endIndex);
  316. const startLine = content.startPosition.row;
  317. const extractor = new TreeSitterExtractor(this.filePath, inner, 'cfscript');
  318. const result = extractor.extract();
  319. // The inner TreeSitterExtractor always synthesizes its own `file`-kind
  320. // node scoped to just this snippet — drop it (and any edges touching it)
  321. // since this tag-based file already owns one correctly-ranged file node
  322. // (see createFileNode); the per-node `parentId` contains-edge below
  323. // already links every emitted symbol into the real tree.
  324. const innerFileNodeId = result.nodes.find((n) => n.kind === 'file')?.id;
  325. // Snippet-top-level symbols are the ones the inner extractor attached
  326. // directly to its (dropped) snippet file node — as opposed to closures
  327. // nested inside another function.
  328. const topLevelIds = new Set(
  329. result.edges
  330. .filter((e) => e.kind === 'contains' && e.source === innerFileNodeId)
  331. .map((e) => e.target)
  332. );
  333. // Snippet-top-level non-callables: `var x = …` locals of the enclosing
  334. // function that the fragment-as-module parse mints as declarations.
  335. const localVarIds = new Set(
  336. result.nodes
  337. .filter((n) => topLevelIds.has(n.id) && (n.kind === 'variable' || n.kind === 'constant'))
  338. .map((n) => n.id)
  339. );
  340. for (const node of result.nodes) {
  341. if (node.kind === 'file') continue;
  342. node.startLine += startLine;
  343. node.endLine += startLine;
  344. node.language = this.language;
  345. if (parentClassName) {
  346. if (node.kind === 'function' && topLevelIds.has(node.id)) {
  347. node.kind = 'method';
  348. }
  349. node.qualifiedName = `${parentClassName}::${node.qualifiedName}`;
  350. }
  351. this.nodes.push(node);
  352. if (parentId) {
  353. this.edges.push({ source: parentId, target: node.id, kind: 'contains' });
  354. }
  355. }
  356. for (const edge of result.edges) {
  357. if (edge.source === innerFileNodeId || edge.target === innerFileNodeId) continue;
  358. if (edge.line) edge.line += startLine;
  359. this.edges.push(edge);
  360. }
  361. for (const ref of result.unresolvedReferences) {
  362. ref.line += startLine;
  363. ref.filePath = this.filePath;
  364. ref.language = this.language;
  365. // Calls inside a <cfscript> body with no enclosing function (rare — a
  366. // top-level script in a .cfm template, or any statement directly in
  367. // the snippet body) attribute to the filtered-out snippet file node by
  368. // default — redirect those (and any genuinely unset ones) to parentId.
  369. // Same for a snippet-top-level `var x = helper()`: the inner extractor
  370. // parses the fragment as a whole module, so it mints a variable node and
  371. // attributes the initializer's calls to it — but this fragment is a
  372. // FUNCTION BODY, so `x` is a local and `helper` is the enclosing
  373. // function's callee. Snippet-top-level FUNCTIONS keep their own calls.
  374. if ((!ref.fromNodeId || ref.fromNodeId === innerFileNodeId || localVarIds.has(ref.fromNodeId)) && parentId) {
  375. ref.fromNodeId = parentId;
  376. }
  377. this.unresolvedReferences.push(ref);
  378. }
  379. for (const error of result.errors) {
  380. if (error.line) error.line += startLine;
  381. this.errors.push(error);
  382. }
  383. }
  384. /**
  385. * Delegate a `<cfquery>...</cfquery>` tag's SQL body to the `cfquery` grammar.
  386. * `#hash#` expressions inside the SQL (e.g. `#getCurrentUser().getId()#` in a
  387. * WHERE clause) are real CFML calls/references — tree-sitter-cfml's `cfquery`
  388. * grammar parses them structurally (same `call_expression`/`member_expression`
  389. * shape as cfscript), so without this delegation they're silently dropped as
  390. * opaque SQL text. The grammar models no other symbols, so only call/reference
  391. * extraction is relevant here — unlike `delegateScriptTag`, there are no nodes
  392. * or contains-edges to merge.
  393. */
  394. private delegateQueryTag(queryTag: SyntaxNode, parentId: string | undefined): void {
  395. const content = queryTag.namedChildren.find((c: SyntaxNode) => c.type === 'cf_query_content');
  396. if (!content) return;
  397. const sql = this.source.substring(content.startIndex, content.endIndex);
  398. const startLine = content.startPosition.row;
  399. const extractor = new TreeSitterExtractor(this.filePath, sql, 'cfquery');
  400. const result = extractor.extract();
  401. const innerFileNodeId = result.nodes.find((n) => n.kind === 'file')?.id;
  402. for (const ref of result.unresolvedReferences) {
  403. ref.line += startLine;
  404. ref.filePath = this.filePath;
  405. ref.language = this.language;
  406. if ((!ref.fromNodeId || ref.fromNodeId === innerFileNodeId) && parentId) ref.fromNodeId = parentId;
  407. this.unresolvedReferences.push(ref);
  408. }
  409. for (const error of result.errors) {
  410. if (error.line) error.line += startLine;
  411. this.errors.push(error);
  412. }
  413. }
  414. /** Read a `cf_attribute`'s value by name from a tag node's direct `cf_attribute`/`cf_tag_attributes` children. */
  415. private tagAttr(tag: SyntaxNode, attrName: string): string | undefined {
  416. const attrs: SyntaxNode[] = [];
  417. for (let i = 0; i < tag.namedChildCount; i++) {
  418. const child = tag.namedChild(i);
  419. if (!child) continue;
  420. if (child.type === 'cf_attribute') attrs.push(child);
  421. else if (child.type === 'cf_tag_attributes') {
  422. for (let j = 0; j < child.namedChildCount; j++) {
  423. const inner = child.namedChild(j);
  424. if (inner?.type === 'cf_attribute') attrs.push(inner);
  425. }
  426. }
  427. }
  428. for (const attr of attrs) {
  429. const nameNode = attr.namedChildren.find((c: SyntaxNode) => c.type === 'cf_attribute_name');
  430. if (!nameNode) continue;
  431. const text = this.source.substring(nameNode.startIndex, nameNode.endIndex);
  432. if (text.toLowerCase() !== attrName.toLowerCase()) continue;
  433. // Values come wrapped as `quoted_cf_attribute_value` (name="init") or bare
  434. // `cf_attribute_value` (name=init — legal and common in older CFML).
  435. const valueWrapper = attr.namedChildren.find(
  436. (c: SyntaxNode) => c.type === 'quoted_cf_attribute_value' || c.type === 'cf_attribute_value'
  437. );
  438. const valueNode = valueWrapper?.namedChildren.find((c: SyntaxNode) => c.type === 'attribute_value');
  439. if (!valueNode) return '';
  440. return this.source.substring(valueNode.startIndex, valueNode.endIndex);
  441. }
  442. return undefined;
  443. }
  444. private componentNameFromPath(): string {
  445. const fileName = this.filePath.split(/[/\\]/).pop() || this.filePath;
  446. return fileName.replace(/\.(cfc|cfm|cfs)$/i, '');
  447. }
  448. }
  449. /**
  450. * Sniff whether CFML source is bare-script (`component { ... }`, modern style)
  451. * vs tag-based (`<cfcomponent>`, `<cfif>`, HTML). Skips a leading UTF-8 BOM
  452. * (endemic in CFML's Windows-editor history — 17% of ColdBox's files carry
  453. * one; both grammars parse fine with it once routed correctly), whitespace,
  454. * and `//`/`/* *\/` comments to find the first real token; tag-based files
  455. * start with `<`, script-based files don't.
  456. */
  457. export function isBareScriptCfml(source: string): boolean {
  458. let i = 0;
  459. const len = source.length;
  460. while (i < len) {
  461. const ch = source[i];
  462. if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r' || ch === '\uFEFF') {
  463. i++;
  464. } else if (ch === '/' && source[i + 1] === '/') {
  465. const nl = source.indexOf('\n', i);
  466. i = nl === -1 ? len : nl + 1;
  467. } else if (ch === '/' && source[i + 1] === '*') {
  468. const end = source.indexOf('*/', i + 2);
  469. i = end === -1 ? len : end + 2;
  470. } else {
  471. return ch !== '<';
  472. }
  473. }
  474. return true; // empty/whitespace-only file — treat as script (no-op extraction either way)
  475. }