erlang.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  1. import type { Node as SyntaxNode } from 'web-tree-sitter';
  2. import { getNodeText, getChildByField, getPrecedingDocstring } from '../tree-sitter-helpers';
  3. import type { LanguageExtractor, ExtractorContext } from '../tree-sitter-types';
  4. // Node names follow the vendored WhatsApp/tree-sitter-erlang grammar (0.19,
  5. // ABI 14) — the grammar behind the Erlang Language Platform (ELP).
  6. //
  7. // Erlang is form-based, and three of its shapes don't fit the generic
  8. // extractor, so every symbol-bearing top-level form is dispatched through the
  9. // visitNode hook below instead:
  10. // - a function's name lives on its CLAUSE, not the fun_decl, and the grammar
  11. // emits one fun_decl PER CLAUSE — consecutive same-name fun_decl forms are
  12. // merged into a single function node here;
  13. // - type-position expressions (-spec/-type/-callback bodies, record field
  14. // types) parse as `call` nodes, so descending into them would mint bogus
  15. // call refs to type names (`pid()`, `term()`); the hook consumes those
  16. // subtrees;
  17. // - record_decl carries its fields as direct children (no body field), which
  18. // the generic extractStruct would skip as a forward declaration.
  19. // Calls (local `f(X)`, remote `mod:f(X)`, `fun f/1` references, and record
  20. // usages) are handled by the erlang branch in extractCall — remote calls are
  21. // emitted as `mod::f`, which matches the qualifiedName the module namespace
  22. // produces (see packageTypes below), so cross-module resolution rides the
  23. // standard qualified-name matcher.
  24. /** Text of an atom with quoted-atom quotes stripped (`'EXIT'` → `EXIT`). */
  25. function atomText(node: SyntaxNode, source: string): string {
  26. return getNodeText(node, source).replace(/^'([\s\S]*)'$/, '$1');
  27. }
  28. function collapseWs(text: string): string {
  29. return text.replace(/\s+/g, ' ').trim();
  30. }
  31. // --- Per-file memos. Extraction is file-sequential within a worker, so a
  32. // single-entry memo keyed by filePath is safe (and resets naturally). ---
  33. /** Exported function names for the current file ('all' for -compile(export_all)). */
  34. let exportsFile = '';
  35. let exportsMemo: Set<string> | 'all' = new Set();
  36. /**
  37. * Clause-merge state: the previous fun_decl's name and node id. A fun_decl
  38. * whose clause repeats that name is a continuation clause (or a same-name
  39. * different-arity definition — deliberately grouped under one node, the way
  40. * overloads are elsewhere) and attaches to the existing node instead of
  41. * creating a duplicate.
  42. */
  43. let lastFnFile = '';
  44. let lastFnName = '';
  45. let lastFnId = '';
  46. function moduleExports(node: SyntaxNode, source: string, filePath: string): Set<string> | 'all' {
  47. if (filePath === exportsFile) return exportsMemo;
  48. let root: SyntaxNode = node;
  49. while (root.parent) root = root.parent;
  50. let result: Set<string> | 'all' = new Set<string>();
  51. for (let i = 0; i < root.namedChildCount; i++) {
  52. const form = root.namedChild(i);
  53. if (!form) continue;
  54. if (
  55. form.type === 'compile_options_attribute' &&
  56. getNodeText(form, source).includes('export_all')
  57. ) {
  58. result = 'all';
  59. break;
  60. }
  61. if (form.type === 'export_attribute') {
  62. for (const fa of form.namedChildren) {
  63. if (fa.type !== 'fa') continue;
  64. const fun = getChildByField(fa, 'fun');
  65. if (fun) result.add(atomText(fun, source));
  66. }
  67. }
  68. }
  69. exportsFile = filePath;
  70. exportsMemo = result;
  71. return result;
  72. }
  73. /** The -spec directly above a function (comments may sit between), if it names it. */
  74. function precedingSpec(node: SyntaxNode, name: string, source: string): SyntaxNode | null {
  75. let prev = node.previousNamedSibling;
  76. while (prev && prev.type === 'comment') prev = prev.previousNamedSibling;
  77. if (prev?.type === 'spec') {
  78. const specFun = getChildByField(prev, 'fun');
  79. if (specFun && atomText(specFun, source) === name) return prev;
  80. }
  81. return null;
  82. }
  83. /** `name(Args) when Guard` — the clause text up to the `->`. */
  84. function clauseHeader(clause: SyntaxNode, source: string): string | undefined {
  85. const body = getChildByField(clause, 'body');
  86. const end = body ? body.startIndex : clause.endIndex;
  87. return collapseWs(source.substring(clause.startIndex, end)) || undefined;
  88. }
  89. function handleFunDecl(node: SyntaxNode, ctx: ExtractorContext): boolean {
  90. const clauses = node.namedChildren.filter((c) => c.type === 'function_clause');
  91. const first = clauses[0];
  92. if (!first) return true; // macro-templated clause (`?M(...) -> ...`) — no static name
  93. const nameNode = getChildByField(first, 'name');
  94. if (!nameNode) return true;
  95. const name = atomText(nameNode, ctx.source);
  96. if (!name) return true;
  97. // Continuation clause: extend the existing node's span and attribute this
  98. // clause's calls to it.
  99. if (ctx.filePath === lastFnFile && name === lastFnName && lastFnId) {
  100. for (let i = ctx.nodes.length - 1; i >= 0; i--) {
  101. const n = ctx.nodes[i];
  102. if (n && n.id === lastFnId) {
  103. if (node.endPosition.row + 1 > n.endLine) n.endLine = node.endPosition.row + 1;
  104. break;
  105. }
  106. }
  107. ctx.pushScope(lastFnId);
  108. for (const clause of clauses) ctx.visitFunctionBody(clause, lastFnId);
  109. ctx.popScope();
  110. return true;
  111. }
  112. const spec = precedingSpec(node, name, ctx.source);
  113. const exports = moduleExports(node, ctx.source, ctx.filePath);
  114. const fn = ctx.createNode('function', name, node, {
  115. docstring: getPrecedingDocstring(spec ?? node, ctx.source),
  116. signature: spec
  117. ? collapseWs(getNodeText(spec, ctx.source)).slice(0, 300)
  118. : clauseHeader(first, ctx.source),
  119. isExported: exports === 'all' || exports.has(name),
  120. });
  121. if (!fn) return true;
  122. ctx.pushScope(fn.id);
  123. // The whole clause is walked (not just the body) so record patterns in the
  124. // arguments and guard calls contribute references too.
  125. for (const clause of clauses) ctx.visitFunctionBody(clause, fn.id);
  126. ctx.popScope();
  127. lastFnFile = ctx.filePath;
  128. lastFnName = name;
  129. lastFnId = fn.id;
  130. return true;
  131. }
  132. function handleRecordDecl(node: SyntaxNode, ctx: ExtractorContext): boolean {
  133. const nameNode = getChildByField(node, 'name');
  134. if (!nameNode) return true;
  135. const rec = ctx.createNode('struct', atomText(nameNode, ctx.source), node, {
  136. docstring: getPrecedingDocstring(node, ctx.source),
  137. signature: collapseWs(getNodeText(node, ctx.source)).slice(0, 300),
  138. });
  139. if (rec) {
  140. ctx.pushScope(rec.id);
  141. for (const field of node.namedChildren) {
  142. if (field.type !== 'record_field') continue;
  143. const fieldName = getChildByField(field, 'name');
  144. if (fieldName) ctx.createNode('field', atomText(fieldName, ctx.source), field);
  145. }
  146. ctx.popScope();
  147. }
  148. return true; // field types/defaults are type-position exprs — don't descend
  149. }
  150. function handleTypeAlias(node: SyntaxNode, ctx: ExtractorContext): boolean {
  151. const typeName = getChildByField(node, 'name'); // type_name wrapper
  152. const nameNode = typeName ? getChildByField(typeName, 'name') : null;
  153. if (nameNode) {
  154. ctx.createNode('type_alias', atomText(nameNode, ctx.source), node, {
  155. signature: collapseWs(getNodeText(node, ctx.source)).slice(0, 200),
  156. });
  157. }
  158. return true;
  159. }
  160. function handlePpDefine(node: SyntaxNode, ctx: ExtractorContext): boolean {
  161. const lhs = getChildByField(node, 'lhs');
  162. const nameNode = lhs ? getChildByField(lhs, 'name') : null;
  163. if (nameNode) {
  164. ctx.createNode('constant', getNodeText(nameNode, ctx.source), node, {
  165. signature: collapseWs(getNodeText(node, ctx.source)).slice(0, 200),
  166. });
  167. }
  168. return true; // the replacement's calls only exist at expansion sites
  169. }
  170. function handleBehaviour(node: SyntaxNode, ctx: ExtractorContext): boolean {
  171. const nameNode = getChildByField(node, 'name');
  172. const parentId = ctx.nodeStack[ctx.nodeStack.length - 1];
  173. if (nameNode && parentId) {
  174. // `-behaviour(x)` implements x's callback contract. Resolves when the
  175. // behaviour module is in the repo; OTP behaviours (gen_server, …) simply
  176. // stay unresolved.
  177. ctx.addUnresolvedReference({
  178. fromNodeId: parentId,
  179. referenceName: atomText(nameNode, ctx.source),
  180. referenceKind: 'implements',
  181. line: node.startPosition.row + 1,
  182. column: node.startPosition.column,
  183. });
  184. }
  185. return true;
  186. }
  187. export const erlangExtractor: LanguageExtractor = {
  188. functionTypes: ['fun_decl'], // dispatched via visitNode (name lives on the clause)
  189. classTypes: [],
  190. methodTypes: [],
  191. interfaceTypes: [],
  192. structTypes: ['record_decl'], // dispatched via visitNode (fields are direct children)
  193. enumTypes: [],
  194. typeAliasTypes: ['type_alias', 'opaque'], // dispatched via visitNode
  195. importTypes: ['import_attribute', 'pp_include', 'pp_include_lib'],
  196. callTypes: [
  197. 'call',
  198. 'internal_fun', // fun f/1
  199. 'external_fun', // fun mod:f/1
  200. 'record_expr', // #rec{...} construction
  201. 'record_update_expr', // X#rec{...}
  202. 'record_index_expr', // #rec.field
  203. 'record_field_expr', // X#rec.field
  204. ],
  205. variableTypes: [],
  206. nameField: 'name',
  207. bodyField: 'body',
  208. paramsField: 'args',
  209. // `-module(m)` wraps the file's declarations in a namespace so every
  210. // function's qualifiedName is `m::f` — which is exactly the reference shape
  211. // the extractCall erlang branch emits for remote calls, so `mod:f(...)`
  212. // resolves through matchByQualifiedName with no resolver changes.
  213. packageTypes: ['module_attribute'],
  214. extractPackage: (node, source) => {
  215. const name = getChildByField(node, 'name');
  216. return name ? atomText(name, source) : null;
  217. },
  218. extractImport: (node, source) => {
  219. if (node.type === 'import_attribute') {
  220. const mod = getChildByField(node, 'module');
  221. if (!mod) return null;
  222. return {
  223. moduleName: atomText(mod, source),
  224. signature: collapseWs(getNodeText(node, source)).slice(0, 200),
  225. };
  226. }
  227. // pp_include / pp_include_lib — a C-include-style file dependency on a .hrl.
  228. const file = getChildByField(node, 'file');
  229. if (!file) return null;
  230. const headerPath = getNodeText(file, source).replace(/^"/, '').replace(/"$/, '');
  231. if (!headerPath) return null;
  232. return { moduleName: headerPath, signature: getNodeText(node, source).trim() };
  233. },
  234. visitNode: (node, ctx) => {
  235. switch (node.type) {
  236. case 'fun_decl':
  237. return handleFunDecl(node, ctx);
  238. case 'record_decl':
  239. return handleRecordDecl(node, ctx);
  240. case 'type_alias':
  241. case 'opaque':
  242. return handleTypeAlias(node, ctx);
  243. case 'pp_define':
  244. return handlePpDefine(node, ctx);
  245. case 'behaviour_attribute':
  246. return handleBehaviour(node, ctx);
  247. // -spec / -callback: their type expressions parse as `call` nodes;
  248. // consume the subtree so the walker doesn't mint bogus call refs.
  249. case 'spec':
  250. case 'callback':
  251. return true;
  252. default:
  253. return false;
  254. }
  255. },
  256. };