diff --git a/packages/uzu/README.md b/packages/uzu/README.md new file mode 100644 index 00000000..53eef51f --- /dev/null +++ b/packages/uzu/README.md @@ -0,0 +1,29 @@ +# uzu + +an experimental parser for an *uzulang*, a custom dsl for patterns that can stand on its own feet. more info: + +- [uzulang I](https://garten.salat.dev/uzu/uzulang1.html) +- [uzulang II](https://garten.salat.dev/uzu/uzulang2.html) + +```js +import { UzuRunner } from 'uzu' + +const runner = UzuRunner({ seq, cat, s, crush, speed, '*': fast }); +const pat = runner.run('s [bd hh*2 cp.(crush 4) ] . speed .8') +``` + +the above code will create the following call structure: + +```lisp +(speed + (s + (seq bd + (* hh 2) + (crush cp 4) + (cat mt ht lt) + ) + ) .8 +) +``` + +you can pass all available functions to *UzuRunner* as an object. diff --git a/packages/uzu/package.json b/packages/uzu/package.json new file mode 100644 index 00000000..9b59078d --- /dev/null +++ b/packages/uzu/package.json @@ -0,0 +1,37 @@ +{ + "name": "uzu", + "version": "1.1.0", + "description": "an uzu notation", + "main": "uzu.mjs", + "type": "module", + "publishConfig": { + "main": "dist/uzu.mjs" + }, + "scripts": { + "test": "vitest run", + "bench": "vitest bench", + "build": "vite build", + "prepublishOnly": "npm run build" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/tidalcycles/strudel.git" + }, + "keywords": [ + "tidalcycles", + "strudel", + "pattern", + "livecoding", + "algorave" + ], + "author": "Felix Roos ", + "license": "AGPL-3.0-or-later", + "bugs": { + "url": "https://github.com/tidalcycles/strudel/issues" + }, + "homepage": "https://github.com/tidalcycles/strudel/blob/main/packages/uzu/README.md", + "devDependencies": { + "vite": "^6.0.11", + "vitest": "^3.0.4" + } +} diff --git a/packages/uzu/test/uzu.test.mjs b/packages/uzu/test/uzu.test.mjs new file mode 100644 index 00000000..c58eb1a8 --- /dev/null +++ b/packages/uzu/test/uzu.test.mjs @@ -0,0 +1,72 @@ +/* +uzu.test.mjs - +Copyright (C) 2022 Strudel contributors - see +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 . +*/ + +import { describe, expect, it } from 'vitest'; +import { UzuParser, UzuRunner, printAst } from '../uzu.mjs'; + +const parser = new UzuParser(); +const p = (code) => parser.parse(code); + +describe('uzu s-expressions parser', () => { + it('should parse an empty string', () => expect(p('')).toEqual({ type: 'list', children: [] })); + it('should parse a single item', () => + expect(p('a')).toEqual({ type: 'list', children: [{ type: 'plain', value: 'a' }] })); + it('should parse an empty list', () => expect(p('()')).toEqual({ type: 'list', children: [] })); + it('should parse a list with 1 item', () => + expect(p('(a)')).toEqual({ type: 'list', children: [{ type: 'plain', value: 'a' }] })); + it('should parse a list with 2 items', () => + expect(p('(a b)')).toEqual({ + type: 'list', + children: [ + { type: 'plain', value: 'a' }, + { type: 'plain', value: 'b' }, + ], + })); + it('should parse a list with 2 items', () => + expect(p('(a (b c))')).toEqual({ + type: 'list', + children: [ + { type: 'plain', value: 'a' }, + { + type: 'list', + children: [ + { type: 'plain', value: 'b' }, + { type: 'plain', value: 'c' }, + ], + }, + ], + })); + it('should parse numbers', () => + expect(p('(1 .2 1.2 10 22.3)')).toEqual({ + type: 'list', + children: [ + { type: 'number', value: '1' }, + { type: 'number', value: '.2' }, + { type: 'number', value: '1.2' }, + { type: 'number', value: '10' }, + { type: 'number', value: '22.3' }, + ], + })); +}); + +let desguar = (a) => { + return printAst(parser.parse(a), true); +}; + +describe('uzu sugar', () => { + it('should desugar []', () => expect(desguar('[a b c]')).toEqual('(seq a b c)')); + it('should desugar [] nested', () => expect(desguar('[a [b c]]')).toEqual('(seq a (seq b c))')); + it('should desugar <>', () => expect(desguar('')).toEqual('(cat a b c)')); + it('should desugar <> nested', () => expect(desguar('>')).toEqual('(cat a (cat b c))')); + it('should desugar mixed [] <>', () => expect(desguar('[a ]')).toEqual('(seq a (cat b c))')); + it('should desugar mixed <> []', () => expect(desguar('')).toEqual('(cat a (seq b c))')); + it('should desugar .', () => expect(desguar('s jazz . fast 2')).toEqual('(fast (s jazz) 2)')); + it('should desugar . twice', () => expect(desguar('s jazz . fast 2 . slow 2')).toEqual('(slow (fast (s jazz) 2) 2)')); + it('should desugar README example', () => + expect(desguar('s [bd hh*2 cp.(crush 4) ] . speed .8')).toEqual( + '(speed (s (seq bd (* hh 2) (crush cp 4) (cat mt ht lt))) .8)', + )); +}); diff --git a/packages/uzu/uzu.mjs b/packages/uzu/uzu.mjs new file mode 100644 index 00000000..ead760be --- /dev/null +++ b/packages/uzu/uzu.mjs @@ -0,0 +1,294 @@ +/* +uzu.mjs - +Copyright (C) 2022 Strudel contributors - see +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 . +*/ + +// evolved from https://garten.salat.dev/lisp/parser.html +export class UzuParser { + // these are the tokens we expect + token_types = { + string: /^\"(.*?)\"/, + open_list: /^\(/, + close_list: /^\)/, + open_cat: /^\/, + open_seq: /^\[/, + close_seq: /^\]/, + number: /^[0-9]*\.?[0-9]+/, // before pipe! + pipe: /^\./, + stack: /^\,/, + op: /^[\*\/]/, + plain: /^[a-zA-Z0-9\-]+/, + }; + // matches next token + next_token(code) { + for (let type in this.token_types) { + const match = code.match(this.token_types[type]); + if (match) { + return { type, value: match[0] }; + } + } + throw new Error(`zilp: could not match '${code}'`); + } + // takes code string, returns list of matched tokens (if valid) + tokenize(code) { + let tokens = []; + while (code.length > 0) { + code = code.trim(); + const token = this.next_token(code); + code = code.slice(token.value.length); + tokens.push(token); + } + return tokens; + } + // take code, return abstract syntax tree + parse(code) { + this.tokens = this.tokenize(code); + const expressions = []; + while (this.tokens.length) { + expressions.push(this.parse_expr()); + } + if (expressions.length === 0) { + // empty case + return { type: 'list', children: [] }; + } + // do we have multiple top level expressions or a single non list? + if (expressions.length > 1 || expressions[0].type !== 'list') { + return { + type: 'list', + children: this.desugar_children(expressions), + }; + } + // we have a single list + return expressions[0]; + } + // parses any valid expression + parse_expr() { + if (!this.tokens[0]) { + throw new Error(`unexpected end of file`); + } + let next = this.tokens[0]?.type; + if (next === 'open_list') { + return this.parse_list(); + } + if (next === 'open_cat') { + return this.parse_cat(); + } + if (next === 'open_seq') { + return this.parse_seq(); + } + return this.consume(next); + } + desugar_children(children) { + children = this.resolve_ops(children); + children = this.resolve_pipes(children); + return children; + } + // Token[] => Token[][] . returns empty list if type not found + split_children(children, type) { + const chunks = []; + while (true) { + let commaIndex = children.findIndex((child) => child.type === type); + if (commaIndex === -1) break; + const chunk = children.slice(0, commaIndex); + chunks.push(chunk); + children = children.slice(commaIndex + 1); + } + if (!chunks.length) { + return []; + } + chunks.push(children); + return chunks; + } + desugar_stack(children) { + let [type, ...rest] = children; + // children is expected to contain seq or cat as first item + const chunks = this.split_children(rest, 'stack'); + if (!chunks.length) { + // no stack + return children; + } + // collect args of stack function + const args = chunks.map((chunk) => { + if (chunk.length === 1) { + // chunks of one element can be added to the stack as is + return chunk[0]; + } else { + // chunks of multiple args are added to a subsequence of type + return { type: 'list', children: [type, ...chunk] }; + } + }); + return [{ type: 'plain', value: 'stack' }, ...args]; + } + resolve_ops(children) { + while (true) { + let opIndex = children.findIndex((child) => child.type === 'op'); + if (opIndex === -1) break; + const op = { type: 'plain', value: children[opIndex].value }; + if (opIndex === children.length - 1) { + throw new Error(`cannot use operator as last child.`); + } + if (opIndex === 0) { + // regular function call (assuming each operator exists as function) + children[opIndex] = op; + continue; + } + const left = children[opIndex - 1]; + const right = children[opIndex + 1]; + if (left.type === 'pipe') { + // "x !* 2" => (* 2 x) + children[opIndex] = op; + continue; + } + // convert infix to prefix notation + const call = { type: 'list', children: [op, left, right] }; + // insert call while keeping other siblings + children = [...children.slice(0, opIndex - 1), call, ...children.slice(opIndex + 2)]; + // unwrap double list.. e.g. (s jazz) * 2 + if (children.length === 1) { + // there might be a cleaner solution + children = children[0].children; + } + } + return children; + } + resolve_pipes(children) { + while (true) { + let pipeIndex = children.findIndex((child) => child.type === 'pipe'); + // no pipe => we're done + if (pipeIndex === -1) break; + // pipe up front => lambda + if (pipeIndex === 0) { + // . as lambda: (.fast 2) = x=>x.fast(2) + // TODO: this doesn't work for (.fast 2 .speed 2) + // probably needs proper ast representation of lambda + children[pipeIndex] = { type: 'plain', value: '.' }; + continue; + } + const rightSide = children.slice(pipeIndex + 2); + const right = children[pipeIndex + 1]; + if (right.type === 'list') { + // apply function only to left sibling (high precedence) + // s jazz.(fast 2) => s (fast jazz 2) + const [callee, ...rest] = right.children; + const leftSide = children.slice(0, pipeIndex - 1); + const left = children[pipeIndex - 1]; + const args = [callee, left, ...rest]; + const call = { type: 'list', children: args }; + children = [...leftSide, call, ...rightSide]; + } else { + // apply function to all left siblings (low precedence) + // s jazz . fast 2 => fast (s jazz) 2 + let leftSide = children.slice(0, pipeIndex); + if (leftSide.length === 1) { + leftSide = leftSide[0]; + } else { + // wrap in (..) if multiple items on the left side + leftSide = { type: 'list', children: leftSide }; + } + children = [right, leftSide, ...rightSide]; + } + } + return children; + } + parse_pair(open_type, close_type) { + this.consume(open_type); + const children = []; + while (this.tokens[0]?.type !== close_type) { + children.push(this.parse_expr()); + } + this.consume(close_type); + return children; + } + parse_list() { + let children = this.parse_pair('open_list', 'close_list'); + children = this.desugar_children(children); + return { type: 'list', children }; + } + parse_cat() { + let children = this.parse_pair('open_cat', 'close_cat'); + children = [{ type: 'plain', value: 'cat' }, ...children]; + children = this.desugar_children(children); + children = this.desugar_stack(children, 'cat'); + return { type: 'list', children }; + } + parse_seq() { + let children = this.parse_pair('open_seq', 'close_seq'); + children = [{ type: 'plain', value: 'seq' }, ...children]; + children = this.desugar_children(children); + children = this.desugar_stack(children, 'seq'); + return { type: 'list', children }; + } + consume(type) { + // shift removes first element and returns it + const token = this.tokens.shift(); + if (token.type !== type) { + throw new Error(`expected token type ${type}, got ${token.type}`); + } + return token; + } +} + +export function printAst(ast, compact = false, lvl = 0) { + const br = compact ? '' : '\n'; + const spaces = compact ? '' : Array(lvl).fill(' ').join(''); + if (ast.type === 'list') { + return `${lvl ? br : ''}${spaces}(${ast.children.map((child) => printAst(child, compact, lvl + 1)).join(' ')}${ + ast.children.find((child) => child.type === 'list') ? `${br}${spaces})` : ')' + }`; + } + return `${ast.value}`; +} + +// lisp runner +export class UzuRunner { + constructor(lib) { + this.parser = new UzuParser(); + this.lib = lib; + } + // a helper to check conditions and throw if they are not met + assert(condition, error) { + if (!condition) { + throw new Error(error); + } + } + run(code) { + const ast = this.parser.parse(code); + return this.call(ast); + } + call(ast) { + // for a node to be callable, it needs to be a list + this.assert(ast.type === 'list', `function call: expected list, got ${ast.type}`); + // the first element is expected to be the function name + this.assert(ast.children[0]?.type === 'plain', `function call: expected first child to be plain, got ${ast.type}`); + + // process args + const args = ast.children.slice(1).map((arg) => { + if (arg.type === 'string') { + return this.lib.string(arg.value.slice(1, -1)); + } + if (arg.type === 'plain') { + return this.lib.plain(arg.value); + } + if (arg.type === 'number') { + return this.lib.number(Number(arg.value)); + } + return this.call(arg); + }); + + const name = ast.children[0].value; + if (name === '.') { + // lambda : (.fast 2) = x=>fast(2, x) + const callee = ast.children[1].value; + const innerFn = this.lib[callee]; + this.assert(innerFn, `function call: unknown function name "${callee}"`); + return (pat) => innerFn(pat, args.slice(1)); + } + + // look up function in lib + const fn = this.lib[name]; + this.assert(fn, `function call: unknown function name "${name}"`); + return fn(...args); + } +} diff --git a/packages/uzu/vite.config.js b/packages/uzu/vite.config.js new file mode 100644 index 00000000..e61a69e4 --- /dev/null +++ b/packages/uzu/vite.config.js @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite'; +//import { dependencies } from './package.json'; +import { resolve } from 'path'; + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [], + build: { + lib: { + entry: resolve(__dirname, 'uzu.mjs'), + formats: ['es'], + fileName: (ext) => ({ es: 'uzu.mjs' })[ext], + }, + rollupOptions: { + // external: [...Object.keys(dependencies)], + }, + target: 'esnext', + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32fd711e..bbe64f07 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,7 +41,7 @@ importers: version: 2.2.7 '@vitest/coverage-v8': specifier: 3.0.4 - version: 3.0.4(vitest@3.0.4(@types/debug@4.1.12)(@types/node@22.10.10)(@vitest/ui@3.0.4)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.37.0)(yaml@2.7.0)) + version: 3.0.4(vitest@3.0.4) '@vitest/ui': specifier: ^3.0.4 version: 3.0.4(vitest@3.0.4) @@ -540,6 +540,15 @@ importers: specifier: ^3.0.4 version: 3.0.4(@types/debug@4.1.12)(@types/node@22.10.10)(@vitest/ui@3.0.4)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.37.0)(yaml@2.7.0) + packages/uzu: + devDependencies: + vite: + specifier: ^6.0.11 + version: 6.0.11(@types/node@22.10.10)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.37.0)(yaml@2.7.0) + vitest: + specifier: ^3.0.4 + version: 3.0.4(@types/debug@4.1.12)(@types/node@22.10.10)(@vitest/ui@3.0.4)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.37.0)(yaml@2.7.0) + packages/web: dependencies: '@strudel/core': @@ -7561,7 +7570,6 @@ packages: workbox-google-analytics@7.0.0: resolution: {integrity: sha512-MEYM1JTn/qiC3DbpvP2BVhyIH+dV/5BjHk756u9VbwuAhu0QHyKscTnisQuz21lfRpOwiS9z4XdqeVAKol0bzg==} - deprecated: It is not compatible with newer versions of GA starting with v4, as long as you are using GAv3 it should be ok, but the package is not longer being maintained workbox-navigation-preload@7.0.0: resolution: {integrity: sha512-juWCSrxo/fiMz3RsvDspeSLGmbgC0U9tKqcUPZBCf35s64wlaLXyn2KdHHXVQrb2cqF7I0Hc9siQalainmnXJA==} @@ -10248,7 +10256,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@3.0.4(vitest@3.0.4(@types/debug@4.1.12)(@types/node@22.10.10)(@vitest/ui@3.0.4)(jiti@2.4.2)(lightningcss@1.29.1)(terser@5.37.0)(yaml@2.7.0))': + '@vitest/coverage-v8@3.0.4(vitest@3.0.4)': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2