jevstrudel.git / packages / transpiler / transpiler.mjs

transpiler.mjs - <short description TODO> Copyright (C) 2022 Strudel contributors - see https://codeberg.org/uzu/strudel/src/branch/main/packages/superdough/superdough.mjs This 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/.

6import { parse } from 'acorn';
7import escodegen from 'escodegen';
8import { walk } from 'estree-walker';
10let languages = new Map();

config = { getLocations: (code: string, offset?: number) => number[][] } see mondough.mjs for example use the language will kick in when the code contains a template literal of type example: mondo... will use language of type "mondo" 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}
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);

Position offset for block-based evaluation

56  let nodeOffset = range && range.length > 0 ? range[0] : 0;

Track declarations to add to strudelScope for block-based eval

59  let scopeDeclarations = [];
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  }

For block-based eval, add scope assignments before the return statement 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  }

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  }
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}

converts label expressions to p calls: "x: y" to "y.p('x')" 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}

List of non-inline widgets that need cleanup These are Pattern.prototype methods that create persistent visualizations (should be repalced by a function call producing an actual list of registered widgets)

211const nonInlineWidgets = ['punchcard', 'spiral', 'scope', 'pitchwheel', 'spectrum', 'pianoroll', 'wordfall'];
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;

Check if this node is a visualizer call

228  const viz = isVisualizerCall(node);
229  if (viz) return viz;

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}

Creates AST nodes for: userDefinedKeys.add('name'); strudelScope.name = name; globalThis.name = name; Used in block-based evaluation to persist variables/functions across blocks We add to both strudelScope (for internal lookups) and globalThis (for direct access) 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}
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}