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}