mirror of
https://codeberg.org/uzu/strudel
synced 2026-07-31 13:21:52 -04:00
585 lines
16 KiB
JavaScript
585 lines
16 KiB
JavaScript
import { getLeafLocations } from '@strudel/mini';
|
|
import { parse } from 'acorn';
|
|
import escodegen from 'escodegen';
|
|
import { walk } from 'estree-walker';
|
|
|
|
let widgetMethods = [];
|
|
export function registerWidgetType(type) {
|
|
widgetMethods.push(type);
|
|
}
|
|
|
|
let 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
|
|
export function registerLanguage(type, config) {
|
|
languages.set(type, config);
|
|
}
|
|
|
|
export function transpiler(input, options = {}) {
|
|
const { wrapAsync = false, addReturn = true, emitMiniLocations = true, emitWidgets = true } = options;
|
|
|
|
const comments = [];
|
|
let ast = parse(input, {
|
|
ecmaVersion: 2022,
|
|
allowAwaitOutsideFunction: true,
|
|
locations: true,
|
|
onComment: comments,
|
|
});
|
|
|
|
const miniDisableRanges = findMiniDisableRanges(comments, input.length);
|
|
let miniLocations = [];
|
|
const collectMiniLocations = (value, node) => {
|
|
const minilang = languages.get('minilang');
|
|
if (minilang) {
|
|
const code = `[${value}]`;
|
|
const locs = minilang.getLocations(code, node.start);
|
|
miniLocations = miniLocations.concat(locs);
|
|
} else {
|
|
const leafLocs = getLeafLocations(`"${value}"`, node.start, input);
|
|
miniLocations = miniLocations.concat(leafLocs);
|
|
}
|
|
};
|
|
let widgets = [];
|
|
|
|
walk(ast, {
|
|
enter(node, parent /* , prop, index */) {
|
|
if (isLanguageLiteral(node)) {
|
|
const { name } = node.tag;
|
|
const language = languages.get(name);
|
|
const code = node.quasi.quasis[0].value.raw;
|
|
const offset = node.quasi.start + 1;
|
|
if (emitMiniLocations) {
|
|
const locs = language.getLocations(code, offset);
|
|
miniLocations = miniLocations.concat(locs);
|
|
}
|
|
this.skip();
|
|
return this.replace(languageWithLocation(name, code, offset));
|
|
}
|
|
if (isTemplateLiteral(node, 'tidal')) {
|
|
const raw = node.quasi.quasis[0].value.raw;
|
|
const offset = node.quasi.start + 1;
|
|
if (emitMiniLocations) {
|
|
const stringLocs = collectHaskellMiniLocations(raw, offset);
|
|
miniLocations = miniLocations.concat(stringLocs);
|
|
}
|
|
this.skip();
|
|
return this.replace(tidalWithLocation(raw, offset));
|
|
}
|
|
if (isBackTickString(node, parent)) {
|
|
if (isMiniDisabled(node.start, miniDisableRanges)) {
|
|
return;
|
|
}
|
|
const { quasis } = node;
|
|
const { raw } = quasis[0].value;
|
|
this.skip();
|
|
emitMiniLocations && collectMiniLocations(raw, node);
|
|
return this.replace(miniWithLocation(raw, node));
|
|
}
|
|
if (isStringWithDoubleQuotes(node)) {
|
|
if (isMiniDisabled(node.start, miniDisableRanges)) {
|
|
return;
|
|
}
|
|
const { value } = node;
|
|
this.skip();
|
|
emitMiniLocations && collectMiniLocations(value, node);
|
|
return this.replace(miniWithLocation(value, node));
|
|
}
|
|
if (isSliderFunction(node)) {
|
|
emitWidgets &&
|
|
widgets.push({
|
|
from: node.arguments[0].start,
|
|
to: node.arguments[0].end,
|
|
value: node.arguments[0].raw, // don't use value!
|
|
min: node.arguments[1]?.value ?? 0,
|
|
max: node.arguments[2]?.value ?? 1,
|
|
step: node.arguments[3]?.value,
|
|
type: 'slider',
|
|
});
|
|
return this.replace(sliderWithLocation(node));
|
|
}
|
|
if (isWidgetMethod(node)) {
|
|
const type = node.callee.property.name;
|
|
const index = widgets.filter((w) => w.type === type).length;
|
|
const widgetConfig = {
|
|
to: node.end,
|
|
index,
|
|
type,
|
|
id: options.id,
|
|
};
|
|
emitWidgets && widgets.push(widgetConfig);
|
|
return this.replace(widgetWithLocation(node, widgetConfig));
|
|
}
|
|
if (isBareSamplesCall(node, parent)) {
|
|
return this.replace(withAwait(node));
|
|
}
|
|
if (isLabelStatement(node)) {
|
|
return this.replace(labelToP(node));
|
|
}
|
|
},
|
|
|
|
leave(node, parent, prop, index) {
|
|
if (!isKabelCall(node)) return;
|
|
|
|
let [expr, ...rest] = node.arguments;
|
|
if (!expr) throw new Error('K(...) requires an expression');
|
|
|
|
if (shouldCallKabelExpression(expr)) {
|
|
expr = {
|
|
type: 'CallExpression',
|
|
callee: expr,
|
|
arguments: [],
|
|
optional: false,
|
|
};
|
|
}
|
|
|
|
const { template, patternExprs } = extractPatternPlaceholders(expr);
|
|
if (patternExprs.length) {
|
|
const workletArgs = [{ type: 'Literal', value: template }, ...patternExprs, ...rest];
|
|
|
|
let callee = node.callee;
|
|
if (callee.type === 'ChainExpression') callee = callee.expression;
|
|
if (callee.type === 'MemberExpression') {
|
|
return this.replace({
|
|
type: 'CallExpression',
|
|
callee: workletMemberAst(callee.object),
|
|
arguments: workletArgs,
|
|
optional: false,
|
|
});
|
|
}
|
|
return this.replace({
|
|
type: 'CallExpression',
|
|
callee: { type: 'Identifier', name: 'worklet' },
|
|
arguments: workletArgs,
|
|
optional: false,
|
|
});
|
|
}
|
|
|
|
const kabelSrc = genExprSource(expr);
|
|
const workletArgs = [{ type: 'Literal', value: kabelSrc }, ...rest];
|
|
|
|
let callee = node.callee;
|
|
if (callee.type === 'ChainExpression') callee = callee.expression;
|
|
if (callee.type === 'MemberExpression') {
|
|
return this.replace({
|
|
type: 'CallExpression',
|
|
callee: workletMemberAst(callee.object),
|
|
arguments: workletArgs,
|
|
optional: false,
|
|
});
|
|
}
|
|
return this.replace({
|
|
type: 'CallExpression',
|
|
callee: { type: 'Identifier', name: 'worklet' },
|
|
arguments: workletArgs,
|
|
optional: false,
|
|
});
|
|
},
|
|
});
|
|
|
|
let { body } = ast;
|
|
|
|
if (!body.length) {
|
|
console.warn('empty body -> fallback to silence');
|
|
body.push({
|
|
type: 'ExpressionStatement',
|
|
expression: {
|
|
type: 'Identifier',
|
|
name: 'silence',
|
|
},
|
|
});
|
|
} else if (!body?.[body.length - 1]?.expression) {
|
|
throw new Error('unexpected ast format without body expression');
|
|
}
|
|
|
|
// add return to last statement
|
|
if (addReturn) {
|
|
const { expression } = body[body.length - 1];
|
|
body[body.length - 1] = {
|
|
type: 'ReturnStatement',
|
|
argument: expression,
|
|
};
|
|
}
|
|
let output = escodegen.generate(ast);
|
|
if (wrapAsync) {
|
|
output = `(async ()=>{${output}})()`;
|
|
}
|
|
if (!emitMiniLocations) {
|
|
return { output };
|
|
}
|
|
return { output, miniLocations, widgets };
|
|
}
|
|
|
|
function isKabelCall(node) {
|
|
if (node.type !== 'CallExpression') return false;
|
|
let callee = node.callee;
|
|
if (callee.type === 'ChainExpression') callee = callee.expression;
|
|
if (callee.type === 'MemberExpression') return !callee.computed && callee.property?.name === 'K';
|
|
return callee.type === 'Identifier' && callee.name === 'K';
|
|
}
|
|
|
|
function shouldCallKabelExpression(expr) {
|
|
if (expr.type !== 'ArrowFunctionExpression' && expr.type !== 'FunctionExpression') {
|
|
return false;
|
|
}
|
|
if (expr.params.length) {
|
|
return false;
|
|
}
|
|
return expr.body?.type === 'BlockStatement';
|
|
}
|
|
|
|
function genExprSource(expr) {
|
|
return escodegen.generate(expr, { format: { semicolons: false } });
|
|
}
|
|
|
|
function extractPatternPlaceholders(expr) {
|
|
const templateExpr = cloneNode(expr);
|
|
const parents = new Map();
|
|
const targets = [];
|
|
|
|
walk(templateExpr, {
|
|
enter(node, parent, prop, index) {
|
|
parents.set(node, { parent, prop, index });
|
|
const patternExpr = getStrudelPatternExpr(node);
|
|
if (patternExpr) {
|
|
targets.push({ node, patternExpr });
|
|
this.skip();
|
|
}
|
|
},
|
|
});
|
|
|
|
if (!targets.length) {
|
|
return { template: genExprSource(templateExpr), patternExprs: [] };
|
|
}
|
|
|
|
targets.sort((a, b) => getPatternNodeOrder(a.node) - getPatternNodeOrder(b.node));
|
|
|
|
const patternExprs = targets.map(({ patternExpr }) => cloneNode(patternExpr));
|
|
|
|
let currentExpr = templateExpr;
|
|
targets.forEach(({ node }, index) => {
|
|
currentExpr = replaceNode(node, placeholderAst(index), parents, currentExpr);
|
|
});
|
|
|
|
const template = genExprSource(currentExpr);
|
|
return { template, patternExprs };
|
|
}
|
|
|
|
function getStrudelPatternExpr(node) {
|
|
if (isStrudelPatternWrap(node)) {
|
|
const arg = node.arguments?.[0];
|
|
if (!arg) {
|
|
throw new Error('S(...) requires an argument');
|
|
}
|
|
return arg;
|
|
}
|
|
if (isMiniCall(node)) {
|
|
return node;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function isStrudelPatternWrap(node) {
|
|
if (node.type !== 'CallExpression') {
|
|
return false;
|
|
}
|
|
const callee = node.callee;
|
|
if (callee.type === 'Identifier') {
|
|
return callee.name === 'S';
|
|
}
|
|
if (callee.type === 'MemberExpression' && !callee.computed) {
|
|
return callee.property?.name === 'S';
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function getMinilangName() {
|
|
const minilang = languages.get('minilang');
|
|
return minilang?.name || 'm';
|
|
}
|
|
|
|
// Used to identify transpiled `m(...)` calls for proper conversion
|
|
// to, say, kabelsalat placeholders
|
|
function isMiniCall(node) {
|
|
if (node.type !== 'CallExpression') {
|
|
return false;
|
|
}
|
|
const callee = node.callee;
|
|
if (callee.type !== 'Identifier') {
|
|
return false;
|
|
}
|
|
if (callee.name !== getMinilangName()) {
|
|
return false;
|
|
}
|
|
const firstArg = node.arguments?.[0];
|
|
return firstArg?.type === 'Literal' && typeof firstArg.value === 'string';
|
|
}
|
|
|
|
// If `start` is available, we use it. If it's already been transpiled
|
|
// to `m(...)`, use the provided offset
|
|
function getPatternNodeOrder(node) {
|
|
if (typeof node.start === 'number') {
|
|
return node.start;
|
|
}
|
|
if (isMiniCall(node)) {
|
|
const offsetArg = node.arguments?.[1];
|
|
if (offsetArg?.type === 'Literal' && typeof offsetArg.value === 'number') {
|
|
return offsetArg.value;
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
function placeholderAst(index) {
|
|
return {
|
|
type: 'MemberExpression',
|
|
object: { type: 'Identifier', name: 'pat' },
|
|
property: { type: 'Literal', value: index },
|
|
computed: true,
|
|
optional: false,
|
|
};
|
|
}
|
|
|
|
function replaceNode(node, replacement, parents, currentRoot) {
|
|
const info = parents.get(node);
|
|
if (!info || !info.parent) {
|
|
return replacement;
|
|
}
|
|
|
|
const { parent, prop, index } = info;
|
|
if (Array.isArray(parent[prop])) {
|
|
parent[prop][index] = replacement;
|
|
} else {
|
|
parent[prop] = replacement;
|
|
}
|
|
parents.set(replacement, { parent, prop, index });
|
|
return currentRoot;
|
|
}
|
|
|
|
function cloneNode(node) {
|
|
return JSON.parse(JSON.stringify(node));
|
|
}
|
|
|
|
function workletMemberAst(objectExpr) {
|
|
return {
|
|
type: 'MemberExpression',
|
|
object: objectExpr,
|
|
property: { type: 'Identifier', name: 'worklet' },
|
|
computed: false,
|
|
optional: false,
|
|
};
|
|
}
|
|
|
|
function isStringWithDoubleQuotes(node, locations, code) {
|
|
if (node.type !== 'Literal') {
|
|
return false;
|
|
}
|
|
return node.raw[0] === '"';
|
|
}
|
|
|
|
function isBackTickString(node, parent) {
|
|
return node.type === 'TemplateLiteral' && parent.type !== 'TaggedTemplateExpression';
|
|
}
|
|
|
|
function miniWithLocation(value, node) {
|
|
const { start: fromOffset } = node;
|
|
|
|
const minilang = languages.get('minilang');
|
|
let name = 'm';
|
|
if (minilang && minilang.name) {
|
|
name = minilang.name; // name is expected to be exported from the package of the minilang
|
|
}
|
|
|
|
return {
|
|
type: 'CallExpression',
|
|
callee: {
|
|
type: 'Identifier',
|
|
name,
|
|
},
|
|
arguments: [
|
|
{ type: 'Literal', value },
|
|
{ type: 'Literal', value: fromOffset },
|
|
],
|
|
optional: false,
|
|
};
|
|
}
|
|
|
|
// these functions are connected to @strudel/codemirror -> slider.mjs
|
|
// maybe someday there will be pluggable transpiler functions, then move this there
|
|
function isSliderFunction(node) {
|
|
return node.type === 'CallExpression' && node.callee.name === 'slider';
|
|
}
|
|
|
|
function isWidgetMethod(node) {
|
|
return node.type === 'CallExpression' && widgetMethods.includes(node.callee.property?.name);
|
|
}
|
|
|
|
function sliderWithLocation(node) {
|
|
const id = 'slider_' + node.arguments[0].start; // use loc of first arg for id
|
|
// add loc as identifier to first argument
|
|
// the sliderWithID function is assumed to be sliderWithID(id, value, min?, max?)
|
|
node.arguments.unshift({
|
|
type: 'Literal',
|
|
value: id,
|
|
raw: id,
|
|
});
|
|
node.callee.name = 'sliderWithID';
|
|
return node;
|
|
}
|
|
|
|
export function getWidgetID(widgetConfig) {
|
|
// the widget id is used as id for the dom element + as key for eventual resources
|
|
// for example, for each scope widget, a new analyser + buffer (large) is created
|
|
// that means, if we use the index index of line position as id, less garbage is generated
|
|
// return `widget_${widgetConfig.to}`; // more gargabe
|
|
//return `widget_${widgetConfig.index}_${widgetConfig.to}`; // also more garbage
|
|
return `${widgetConfig.id || ''}_widget_${widgetConfig.type}_${widgetConfig.index}`; // less garbage
|
|
}
|
|
|
|
function widgetWithLocation(node, widgetConfig) {
|
|
const id = getWidgetID(widgetConfig);
|
|
// add loc as identifier to first argument
|
|
// the sliderWithID function is assumed to be sliderWithID(id, value, min?, max?)
|
|
node.arguments.unshift({
|
|
type: 'Literal',
|
|
value: id,
|
|
raw: id,
|
|
});
|
|
return node;
|
|
}
|
|
|
|
function isBareSamplesCall(node, parent) {
|
|
return node.type === 'CallExpression' && node.callee.name === 'samples' && parent.type !== 'AwaitExpression';
|
|
}
|
|
|
|
function withAwait(node) {
|
|
return {
|
|
type: 'AwaitExpression',
|
|
argument: node,
|
|
};
|
|
}
|
|
|
|
function isLabelStatement(node) {
|
|
return node.type === 'LabeledStatement';
|
|
}
|
|
|
|
// converts label expressions to p calls: "x: y" to "y.p('x')"
|
|
// see https://codeberg.org/uzu/strudel/issues/990
|
|
function labelToP(node) {
|
|
return {
|
|
type: 'ExpressionStatement',
|
|
expression: {
|
|
type: 'CallExpression',
|
|
callee: {
|
|
type: 'MemberExpression',
|
|
object: node.body.expression,
|
|
property: {
|
|
type: 'Identifier',
|
|
name: 'p',
|
|
},
|
|
},
|
|
arguments: [
|
|
{
|
|
type: 'Literal',
|
|
value: node.label.name,
|
|
raw: `'${node.label.name}'`,
|
|
},
|
|
],
|
|
},
|
|
};
|
|
}
|
|
|
|
function isLanguageLiteral(node) {
|
|
return node.type === 'TaggedTemplateExpression' && languages.has(node.tag.name);
|
|
}
|
|
|
|
// tidal highlighting
|
|
// this feels kind of stupid, when we also know the location inside the string op (tidal.mjs)
|
|
// but maybe it's the only way
|
|
|
|
function isTemplateLiteral(node, value) {
|
|
return node.type === 'TaggedTemplateExpression' && node.tag.name === value;
|
|
}
|
|
|
|
function collectHaskellMiniLocations(haskellCode, offset) {
|
|
return haskellCode
|
|
.split('')
|
|
.reduce((acc, char, i) => {
|
|
if (char !== '"') {
|
|
return acc;
|
|
}
|
|
if (!acc.length || acc[acc.length - 1].length > 1) {
|
|
acc.push([i + 1]);
|
|
} else {
|
|
acc[acc.length - 1].push(i);
|
|
}
|
|
return acc;
|
|
}, [])
|
|
.map(([start, end]) => {
|
|
const miniString = haskellCode.slice(start, end);
|
|
return getLeafLocations(`"${miniString}"`, offset + start - 1);
|
|
})
|
|
.flat();
|
|
}
|
|
|
|
function tidalWithLocation(value, offset) {
|
|
return {
|
|
type: 'CallExpression',
|
|
callee: {
|
|
type: 'Identifier',
|
|
name: 'tidal',
|
|
},
|
|
arguments: [
|
|
{ type: 'Literal', value },
|
|
{ type: 'Literal', value: offset },
|
|
],
|
|
optional: false,
|
|
};
|
|
}
|
|
|
|
function languageWithLocation(name, value, offset) {
|
|
return {
|
|
type: 'CallExpression',
|
|
callee: {
|
|
type: 'Identifier',
|
|
name: name,
|
|
},
|
|
arguments: [
|
|
{ type: 'Literal', value },
|
|
{ type: 'Literal', value: offset },
|
|
],
|
|
optional: false,
|
|
};
|
|
}
|
|
|
|
function findMiniDisableRanges(comments, codeEnd) {
|
|
const ranges = [];
|
|
const stack = []; // used to track on/off pairs
|
|
for (const comment of comments) {
|
|
const value = comment.value.trim();
|
|
if (value.startsWith('mini-off')) {
|
|
stack.push(comment.start);
|
|
} else if (value.startsWith('mini-on')) {
|
|
const start = stack.pop();
|
|
ranges.push([start, comment.end]);
|
|
}
|
|
}
|
|
while (stack.length) {
|
|
// If no closing mini-on is found, just turn it off until `codeEnd`
|
|
const start = stack.pop();
|
|
ranges.push([start, codeEnd]);
|
|
}
|
|
return ranges;
|
|
}
|
|
|
|
function isMiniDisabled(offset, miniDisableRanges) {
|
|
for (const [start, end] of miniDisableRanges) {
|
|
if (offset >= start && offset < end) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|