jevstrudel.git / packages / transpiler / transpiler.mjs
1/*
2transpiler.mjs - <short description TODO>
3Copyright (C) 2022 Strudel contributors - see <https://codeberg.org/uzu/strudel/src/branch/main/packages/superdough/superdough.mjs>
4This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program.  If not, see <https://www.gnu.org/licenses/>.
5*/
6import { parse } from 'acorn';
7import escodegen from 'escodegen';
8import { walk } from 'estree-walker';
9
10let languages = new Map();
11// config = { getLocations: (code: string, offset?: number) => number[][] }
12// see mondough.mjs for example use
13// the language will kick in when the code contains a template literal of type
14// example: mondo`...` will use language of type "mondo"
15// TODO: refactor tidal.mjs to use this
16export function registerLanguage(type, config) {
17  languages.set(type, config);
18}
19export function getLanguages() {
20  return languages;
21}
22
23const plugins = [];
24
25export function registerTranspilerPlugin(plugin) {
26  plugins.push(plugin);
27}
28export function getPlugins() {
29  return plugins.flat(Infinity);
30}
31
32export function transpiler(input, options = {}) {
33  options = {
34    wrapAsync: false,
35    addReturn: true,
36    emitMiniLocations: true,
37    emitWidgets: true,
38    blockBased: false,
39    range: [],
40    ...options,
41  };
42
43  const { wrapAsync, addReturn, emitMiniLocations, emitWidgets, blockBased, range } = options;
44
45  const comments = [];
46  let ast = parse(input, {
47    ecmaVersion: 2022,
48    allowAwaitOutsideFunction: true,
49    locations: true,
50    onComment: comments,
51  });
52
53  let miniDisableRanges = findMiniDisableRanges(comments, input.length);
54
55  // Position offset for block-based evaluation
56  let nodeOffset = range && range.length > 0 ? range[0] : 0;
57
58  // Track declarations to add to strudelScope for block-based eval
59  let scopeDeclarations = [];
60
61  let labels = [];
62
63  const context = { options, input, nodeOffset, miniDisableRanges, labels };
64  const plugins = getPlugins().map((plugin) => plugin.walk?.(context));
65
66  walk(ast, {
67    enter(node, parent, prop, index) {
68      // Apply position offset for block-based evaluation
69      if (blockBased && node.start !== undefined) {
70        node.start = node.start + nodeOffset;
71        node.end = node.end + nodeOffset;
72      }
73      // Collect variable and function declarations for strudelScope (block-based eval)
74      if (blockBased && parent?.type === 'Program') {
75        if (node.type === 'VariableDeclaration') {
76          for (const declarator of node.declarations) {
77            if (declarator.id?.name) {
78              scopeDeclarations.push(declarator.id.name);
79            }
80          }
81        } else if (node.type === 'FunctionDeclaration' && node.id?.name) {
82          scopeDeclarations.push(node.id.name);
83        }
84      }
85
86      for (const plugin of plugins) {
87        if (!plugin?.enter?.call(this, node, parent, prop, index)) continue;
88        return;
89      }
90
91      if (isLabelStatement(node)) {
92        // Collect label info for block-based evaluation
93        // Store positions WITHOUT offset so repl can slice the transpiler output correctly
94        if (blockBased) {
95          labels.push({
96            name: node.label.name,
97            index: node.start - nodeOffset,
98            end: node.label.end - nodeOffset,
99            fullMatch: input.slice(node.start - nodeOffset, node.label.end - nodeOffset),
100            activeVisualizer: findVisualizerInSubtree(node.body),
101          });
102        }
103        return this.replace(labelToP(node));
104      }
105      // Detect all() calls as special labels for block management
106      // Store positions WITHOUT offset so repl can slice the transpiler output correctly
107      if (blockBased && isAllCall(node)) {
108        labels.push({
109          name: 'all',
110          index: node.start - nodeOffset,
111          end: node.end - nodeOffset,
112          fullMatch: input.slice(node.start - nodeOffset, node.end - nodeOffset),
113          activeVisualizer: node.arguments[0] ? findVisualizerInSubtree(node.arguments[0]) : null,
114        });
115      }
116    },
117
118    leave(node, parent, prop, index) {
119      for (const plugin of plugins) {
120        if (!plugin?.leave?.call(this, node, parent, prop, index)) continue;
121        return;
122      }
123    },
124  });
125
126  let { body } = ast;
127
128  const silenceExpression = {
129    type: 'ExpressionStatement',
130    expression: {
131      type: 'Identifier',
132      name: 'silence',
133    },
134  };
135
136  if (!body.length) {
137    console.warn('empty body -> fallback to silence');
138    body.push(silenceExpression);
139  } else if (!body?.[body.length - 1]?.expression) {
140    // Last statement is not an expression (e.g., VariableDeclaration, FunctionDeclaration)
141    body.push(silenceExpression);
142  }
143
144  // For block-based eval, add scope assignments before the return statement
145  // This allows variables/functions defined in one block to be used in other blocks
146  if (blockBased && scopeDeclarations.length > 0) {
147    const scopeAssignments = scopeDeclarations.flatMap((name) => createScopeAssignment(name));
148    // Insert scope assignments before the last statement (which will become the return)
149    body.splice(body.length - 1, 0, ...scopeAssignments);
150  }
151
152  // add return to last statement
153  if (addReturn) {
154    const { expression } = body[body.length - 1];
155    body[body.length - 1] = {
156      type: 'ReturnStatement',
157      argument: expression,
158    };
159  }
160  let output = escodegen.generate(ast);
161  if (wrapAsync) {
162    output = `(async ()=>{${output}})()`;
163  }
164  if (!emitMiniLocations) {
165    return { output };
166  }
167
168  let pluginContext;
169  ({ options, input, miniDisableRanges, nodeOffset, ...pluginContext } = context);
170
171  return { output, ...pluginContext };
172}
173
174function isAllCall(node) {
175  return node.type === 'CallExpression' && node.callee.name === 'all';
176}
177
178function isLabelStatement(node) {
179  return node.type === 'LabeledStatement';
180}
181
182// converts label expressions to p calls: "x: y" to "y.p('x')"
183// see https://codeberg.org/uzu/strudel/issues/990
184function labelToP(node) {
185  return {
186    type: 'ExpressionStatement',
187    expression: {
188      type: 'CallExpression',
189      callee: {
190        type: 'MemberExpression',
191        object: node.body.expression,
192        property: {
193          type: 'Identifier',
194          name: 'p',
195        },
196      },
197      arguments: [
198        {
199          type: 'Literal',
200          value: node.label.name,
201          raw: `'${node.label.name}'`,
202        },
203      ],
204    },
205  };
206}
207
208// List of non-inline widgets that need cleanup
209// These are Pattern.prototype methods that create persistent visualizations
210// (should be repalced by a function call producing an actual list of registered widgets)
211const nonInlineWidgets = ['punchcard', 'spiral', 'scope', 'pitchwheel', 'spectrum', 'pianoroll', 'wordfall'];
212
213function isVisualizerCall(node) {
214  if (
215    node.type === 'CallExpression' &&
216    node.callee.type === 'MemberExpression' &&
217    nonInlineWidgets.includes(node.callee.property?.name)
218  ) {
219    return node.callee.property.name;
220  }
221  return null;
222}
223
224function findVisualizerInSubtree(node) {
225  if (!node || typeof node !== 'object') return null;
226
227  // Check if this node is a visualizer call
228  const viz = isVisualizerCall(node);
229  if (viz) return viz;
230
231  // Recursively search children
232  for (const key of Object.keys(node)) {
233    if (key === 'parent') continue; // Skip parent references to avoid cycles
234    const child = node[key];
235    if (Array.isArray(child)) {
236      for (const item of child) {
237        const found = findVisualizerInSubtree(item);
238        if (found) return found;
239      }
240    } else if (child && typeof child === 'object' && child.type) {
241      const found = findVisualizerInSubtree(child);
242      if (found) return found;
243    }
244  }
245  return null;
246}
247
248// Creates AST nodes for: userDefinedKeys.add('name'); strudelScope.name = name; globalThis.name = name;
249// Used in block-based evaluation to persist variables/functions across blocks
250// We add to both strudelScope (for internal lookups) and globalThis (for direct access)
251// We also track the key in userDefinedKeys so clearScope() can remove it later
252function createScopeAssignment(name) {
253  return [
254    // userDefinedKeys.add('name');
255    {
256      type: 'ExpressionStatement',
257      expression: {
258        type: 'CallExpression',
259        callee: {
260          type: 'MemberExpression',
261          object: {
262            type: 'Identifier',
263            name: 'userDefinedKeys',
264          },
265          property: {
266            type: 'Identifier',
267            name: 'add',
268          },
269          computed: false,
270        },
271        arguments: [
272          {
273            type: 'Literal',
274            value: name,
275          },
276        ],
277      },
278    },
279    // strudelScope.name = name;
280    {
281      type: 'ExpressionStatement',
282      expression: {
283        type: 'AssignmentExpression',
284        operator: '=',
285        left: {
286          type: 'MemberExpression',
287          object: {
288            type: 'Identifier',
289            name: 'strudelScope',
290          },
291          property: {
292            type: 'Identifier',
293            name: name,
294          },
295          computed: false,
296        },
297        right: {
298          type: 'Identifier',
299          name: name,
300        },
301      },
302    },
303    // globalThis.name = name;
304    {
305      type: 'ExpressionStatement',
306      expression: {
307        type: 'AssignmentExpression',
308        operator: '=',
309        left: {
310          type: 'MemberExpression',
311          object: {
312            type: 'Identifier',
313            name: 'globalThis',
314          },
315          property: {
316            type: 'Identifier',
317            name: name,
318          },
319          computed: false,
320        },
321        right: {
322          type: 'Identifier',
323          name: name,
324        },
325      },
326    },
327  ];
328}
329
330function findMiniDisableRanges(comments, codeEnd) {
331  const ranges = [];
332  const stack = []; // used to track on/off pairs
333  for (const comment of comments) {
334    const value = comment.value.trim();
335    if (value.startsWith('mini-off')) {
336      stack.push(comment.start);
337    } else if (value.startsWith('mini-on')) {
338      const start = stack.pop();
339      ranges.push([start, comment.end]);
340    }
341  }
342  while (stack.length) {
343    // If no closing mini-on is found, just turn it off until `codeEnd`
344    const start = stack.pop();
345    ranges.push([start, codeEnd]);
346  }
347  return ranges;
348}