erlang.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347
  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) return true;
  164. const macro = ctx.createNode('constant', getNodeText(nameNode, ctx.source), node, {
  165. signature: collapseWs(getNodeText(node, ctx.source)).slice(0, 200),
  166. });
  167. // The replacement's calls execute at expansion sites, but attributing them
  168. // to the MACRO node keeps them true exactly once: `-define(LOG_AUDIT(E),
  169. // audit_logger:log(E))` gives the LOG_AUDIT constant a `calls` edge to the
  170. // logger, and each `?LOG_AUDIT(...)` use site links to the constant (see the
  171. // macro_call_expr case in extractCall) — so the chain
  172. // `caller → LOG_AUDIT → audit_logger:log` traverses without minting a
  173. // per-use duplicate of the body's calls.
  174. const replacement = getChildByField(node, 'replacement');
  175. if (macro && replacement) {
  176. ctx.pushScope(macro.id);
  177. ctx.visitFunctionBody(replacement, macro.id);
  178. ctx.popScope();
  179. }
  180. return true;
  181. }
  182. function handleBehaviour(node: SyntaxNode, ctx: ExtractorContext): boolean {
  183. const nameNode = getChildByField(node, 'name');
  184. const parentId = ctx.nodeStack[ctx.nodeStack.length - 1];
  185. if (nameNode && parentId) {
  186. // `-behaviour(x)` implements x's callback contract. Resolves when the
  187. // behaviour module is in the repo; OTP behaviours (gen_server, …) simply
  188. // stay unresolved.
  189. ctx.addUnresolvedReference({
  190. fromNodeId: parentId,
  191. referenceName: atomText(nameNode, ctx.source),
  192. referenceKind: 'implements',
  193. line: node.startPosition.row + 1,
  194. column: node.startPosition.column,
  195. });
  196. }
  197. return true;
  198. }
  199. /**
  200. * OTP application resource file (`<app>.app.src` / `<app>.app`): a single
  201. * `{application, Name, Props}.` term the grammar parses as a top-level
  202. * expression. Two properties carry graph structure — `{mod, {Mod, _Args}}`
  203. * names the application-callback module (the app's entry point), and
  204. * `{applications, [...]}` / `{included_applications, [...]}` declare the apps
  205. * this one depends on. In an umbrella repo those resolve to the sibling app's
  206. * module of the same name (the OTP convention); kernel/stdlib and other
  207. * out-of-repo apps stay unresolved.
  208. */
  209. function handleAppResourceTuple(node: SyntaxNode, ctx: ExtractorContext): boolean {
  210. const parentId = ctx.nodeStack[ctx.nodeStack.length - 1];
  211. const props = node.namedChildren[2];
  212. if (!parentId || props?.type !== 'list') return true;
  213. const ref = (nameNode: SyntaxNode, kind: 'references' | 'imports'): void => {
  214. const name = atomText(nameNode, ctx.source);
  215. if (!name) return;
  216. ctx.addUnresolvedReference({
  217. fromNodeId: parentId,
  218. referenceName: name,
  219. referenceKind: kind,
  220. line: nameNode.startPosition.row + 1,
  221. column: nameNode.startPosition.column,
  222. });
  223. };
  224. for (const prop of props.namedChildren) {
  225. if (prop.type !== 'tuple' || prop.namedChildren.length < 2) continue;
  226. const key = prop.namedChildren[0];
  227. const value = prop.namedChildren[1];
  228. if (!key || key.type !== 'atom' || !value) continue;
  229. const keyName = atomText(key, ctx.source);
  230. if (keyName === 'mod' && value.type === 'tuple') {
  231. const mod = value.namedChildren[0];
  232. if (mod?.type === 'atom') ref(mod, 'references');
  233. } else if (
  234. (keyName === 'applications' || keyName === 'included_applications') &&
  235. value.type === 'list'
  236. ) {
  237. for (const app of value.namedChildren) {
  238. if (app.type === 'atom') ref(app, 'imports');
  239. }
  240. }
  241. }
  242. return true; // nothing else in an app term carries graph structure
  243. }
  244. export const erlangExtractor: LanguageExtractor = {
  245. functionTypes: ['fun_decl'], // dispatched via visitNode (name lives on the clause)
  246. classTypes: [],
  247. methodTypes: [],
  248. interfaceTypes: [],
  249. structTypes: ['record_decl'], // dispatched via visitNode (fields are direct children)
  250. enumTypes: [],
  251. typeAliasTypes: ['type_alias', 'opaque'], // dispatched via visitNode
  252. importTypes: ['import_attribute', 'pp_include', 'pp_include_lib'],
  253. callTypes: [
  254. 'call',
  255. 'internal_fun', // fun f/1
  256. 'external_fun', // fun mod:f/1
  257. 'record_expr', // #rec{...} construction
  258. 'record_update_expr', // X#rec{...}
  259. 'record_index_expr', // #rec.field
  260. 'record_field_expr', // X#rec.field
  261. 'macro_call_expr', // ?MACRO / ?MACRO(...) — links use sites to the -define constant
  262. ],
  263. variableTypes: [],
  264. nameField: 'name',
  265. bodyField: 'body',
  266. paramsField: 'args',
  267. // `-module(m)` wraps the file's declarations in a namespace so every
  268. // function's qualifiedName is `m::f` — which is exactly the reference shape
  269. // the extractCall erlang branch emits for remote calls, so `mod:f(...)`
  270. // resolves through matchByQualifiedName with no resolver changes.
  271. packageTypes: ['module_attribute'],
  272. extractPackage: (node, source) => {
  273. const name = getChildByField(node, 'name');
  274. return name ? atomText(name, source) : null;
  275. },
  276. extractImport: (node, source) => {
  277. if (node.type === 'import_attribute') {
  278. const mod = getChildByField(node, 'module');
  279. if (!mod) return null;
  280. return {
  281. moduleName: atomText(mod, source),
  282. signature: collapseWs(getNodeText(node, source)).slice(0, 200),
  283. };
  284. }
  285. // pp_include / pp_include_lib — a C-include-style file dependency on a .hrl.
  286. const file = getChildByField(node, 'file');
  287. if (!file) return null;
  288. const headerPath = getNodeText(file, source).replace(/^"/, '').replace(/"$/, '');
  289. if (!headerPath) return null;
  290. return { moduleName: headerPath, signature: getNodeText(node, source).trim() };
  291. },
  292. visitNode: (node, ctx) => {
  293. switch (node.type) {
  294. case 'fun_decl':
  295. return handleFunDecl(node, ctx);
  296. case 'record_decl':
  297. return handleRecordDecl(node, ctx);
  298. case 'type_alias':
  299. case 'opaque':
  300. return handleTypeAlias(node, ctx);
  301. case 'pp_define':
  302. return handlePpDefine(node, ctx);
  303. case 'behaviour_attribute':
  304. return handleBehaviour(node, ctx);
  305. // -spec / -callback: their type expressions parse as `call` nodes;
  306. // consume the subtree so the walker doesn't mint bogus call refs.
  307. case 'spec':
  308. case 'callback':
  309. return true;
  310. // `{application, Name, Props}.` at the top of an .app/.app.src resource
  311. // file (never a valid form in a module, so the gate is file + position).
  312. case 'tuple':
  313. if (
  314. node.parent?.type === 'source_file' &&
  315. /\.app(?:\.src)?$/i.test(ctx.filePath) &&
  316. node.namedChildren[0]?.type === 'atom' &&
  317. atomText(node.namedChildren[0]!, ctx.source) === 'application'
  318. ) {
  319. return handleAppResourceTuple(node, ctx);
  320. }
  321. return false;
  322. default:
  323. return false;
  324. }
  325. },
  326. };