From 17284b0cd8f229d7bce3ca8c117dffa4df439ab3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hl=C3=B6=C3=B0ver=20Sigur=C3=B0sson?= Date: Wed, 12 Aug 2026 11:32:27 +0300 Subject: [PATCH 1/4] chore(deps): update CodeMirror and Csound browser --- package-lock.json | 134 ++++++++++++++-------------- package.json | 15 ++-- src/components/editor/utils.test.ts | 79 ++++++++++++++++ src/components/editor/utils.ts | 59 ++++++------ src/global.d.ts | 1 - 5 files changed, 185 insertions(+), 103 deletions(-) create mode 100644 src/components/editor/utils.test.ts diff --git a/package-lock.json b/package-lock.json index d6bd844d..83afb9ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,12 @@ "name": "web-ide", "version": "0.1.0", "dependencies": { - "@csound/browser": "^7.0.0-beta29", + "@codemirror/autocomplete": "^6.20.3", + "@codemirror/commands": "^6.10.4", + "@codemirror/language": "^6.12.4", + "@codemirror/state": "^6.7.1", + "@codemirror/view": "^6.43.8", + "@csound/browser": "^7.0.0-beta32", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@emotion/core": "^11.0.0", @@ -16,11 +21,13 @@ "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@hello-pangea/dnd": "^18.0.1", - "@hlolli/codemirror-lang-csound": "^1.0.0-alpha10", + "@hlolli/codemirror-lang-csound": "^1.0.0-alpha11", + "@lezer/common": "^1.5.2", "@mui/icons-material": "^6.2.0", "@mui/material": "^6.2.0", "@reduxjs/toolkit": "^2.5.0", "@vitejs/plugin-react": "^4.3.4", + "codemirror": "^6.0.2", "date-fns": "^4.1.0", "firebase-functions": "^6.4.0", "history": "5.3.0", @@ -84,7 +91,6 @@ "babel-loader": "9.2.1", "babel-plugin-named-asset-import": "^0.3.8", "camelcase": "^8.0.0", - "codemirror": "^6.0.1", "concurrently": "^9.1.0", "cross-env": "^7.0.3", "css-loader": "7.1.2", @@ -1983,101 +1989,90 @@ } }, "node_modules/@codemirror/autocomplete": { - "version": "6.18.3", - "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.18.3.tgz", - "integrity": "sha512-1dNIOmiM0z4BIBwxmxEfA1yoxh1MF/6KPBbh20a5vphGV0ictKlgQsbJs6D6SkR6iJpGbpwRsa6PFMNlg9T9pQ==", + "version": "6.20.3", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", + "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==", "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" - }, - "peerDependencies": { - "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.0.0", - "@lezer/common": "^1.0.0" } }, "node_modules/@codemirror/commands": { - "version": "6.7.1", - "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.7.1.tgz", - "integrity": "sha512-llTrboQYw5H4THfhN4U3qCnSZ1SOJ60ohhz+SzU0ADGtwlc533DtklQP0vSFaQuCPDn3BPpOd1GbbnUtwNjsrw==", + "version": "6.10.4", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz", + "integrity": "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==", "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0", - "@codemirror/state": "^6.4.0", + "@codemirror/state": "^6.7.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "node_modules/@codemirror/language": { - "version": "6.10.6", - "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.10.6.tgz", - "integrity": "sha512-KrsbdCnxEztLVbB5PycWXFxas4EOyk/fPAfruSOnDDppevQgid2XZ+KbJ9u+fDikP/e7MW7HPBTvTb8JlZK9vA==", + "version": "6.12.4", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz", + "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", "license": "MIT", "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", - "@lezer/common": "^1.1.0", + "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "node_modules/@codemirror/lint": { - "version": "6.8.4", - "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.4.tgz", - "integrity": "sha512-u4q7PnZlJUojeRe8FJa/njJcMctISGgPQ4PnWsd9268R4ZTtU+tfFYmwkBvgcrK2+QQ8tYFVALVb5fVJykKc5A==", + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz", + "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==", "license": "MIT", "dependencies": { "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.35.0", + "@codemirror/view": "^6.42.0", "crelt": "^1.0.5" } }, "node_modules/@codemirror/search": { - "version": "6.5.8", - "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.8.tgz", - "integrity": "sha512-PoWtZvo7c1XFeZWmmyaOp2G0XVbOnm+fJzvghqGAktBW3cufwJUWvSCcNG0ppXiBEM05mZu6RhMtXPv2hpllig==", + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.1.tgz", + "integrity": "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==", "license": "MIT", "dependencies": { "@codemirror/state": "^6.0.0", - "@codemirror/view": "^6.0.0", + "@codemirror/view": "^6.37.0", "crelt": "^1.0.5" } }, "node_modules/@codemirror/state": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz", - "integrity": "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==", + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz", + "integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==", "license": "MIT", "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, - "node_modules/@codemirror/text": { - "version": "0.19.6", - "resolved": "https://registry.npmjs.org/@codemirror/text/-/text-0.19.6.tgz", - "integrity": "sha512-T9jnREMIygx+TPC1bOuepz18maGq/92q2a+n4qTqObKwvNMg+8cMTslb8yxeEDEq7S3kpgGWxgO1UWbQRij0dA==", - "license": "MIT" - }, "node_modules/@codemirror/view": { - "version": "6.35.3", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.35.3.tgz", - "integrity": "sha512-ScY7L8+EGdPl4QtoBiOzE4FELp7JmNUsBvgBcCakXWM2uiv/K89VAzU3BMDscf0DsACLvTKePbd5+cFDTcei6g==", + "version": "6.43.8", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.8.tgz", + "integrity": "sha512-qtItTDssZ/5GFfi94hrILu9j/VUeFPDPkhovEfmWFj2ipTxnzPB8DdHgfbb8HYTzLTYhrndKmyQxXUz/PDLenw==", "license": "MIT", "dependencies": { - "@codemirror/state": "^6.5.0", + "@codemirror/state": "^6.7.0", + "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "node_modules/@csound/browser": { - "version": "7.0.0-beta29", - "resolved": "https://registry.npmjs.org/@csound/browser/-/browser-7.0.0-beta29.tgz", - "integrity": "sha512-3yHflHJS6mVTi4LAdXGZOBIgs9eQHrQqNCEoF38Q+DnOspEUUb3YIT7VjzlA4AkFa7rNMzT0zAgwqucd1nfFlQ==", + "version": "7.0.0-beta32", + "resolved": "https://registry.npmjs.org/@csound/browser/-/browser-7.0.0-beta32.tgz", + "integrity": "sha512-A9oDfaGRR89pQho4U7ub338sblq3CTSJh17FhZ8FwrFb3hDxt8Glvh5+gkNaJ3APThyH1ziH7zkYAxwpSZFxWQ==", "license": "Apache-2.0", "dependencies": { "eventemitter3": "^4.0.7", @@ -2090,6 +2085,9 @@ "text-encoding-shim": "^1.0.5", "unmute-ios-audio": "^3.3.0", "web-midi-api": "^2.1.8" + }, + "engines": { + "node": ">=18.18" } }, "node_modules/@csound/browser/node_modules/ramda": { @@ -4255,20 +4253,18 @@ } }, "node_modules/@hlolli/codemirror-lang-csound": { - "version": "1.0.0-alpha10", - "resolved": "https://registry.npmjs.org/@hlolli/codemirror-lang-csound/-/codemirror-lang-csound-1.0.0-alpha10.tgz", - "integrity": "sha512-pcI++pcME4oShgy6teaGzHNWoW9Cp3zdFGH/W5L2EYTkyRA4x+0DK5E7hWRmDDIhdlJE0xqaCrU4GH3oeD1GAA==", + "version": "1.0.0-alpha11", + "resolved": "https://registry.npmjs.org/@hlolli/codemirror-lang-csound/-/codemirror-lang-csound-1.0.0-alpha11.tgz", + "integrity": "sha512-rAxxRgc7sOEQw675Bfdh2N0sai7tImQ3a7J5Tqaux4g3ebRejHHe++YpwtmoHra7mIYC4ZofeUnBJ5eV6Fz15A==", "license": "LGPL-2.0", "dependencies": { - "@codemirror/text": "^0.19.6", - "ramda": "^0.30.1", - "style-mod": "^4.1.2" - }, - "peerDependencies": { - "@codemirror/language": "6.x", - "@codemirror/state": "6.x", - "@codemirror/view": "6.x", - "codemirror": "6.x" + "@codemirror/autocomplete": "^6.20.3", + "@codemirror/language": "^6.12.4", + "@codemirror/state": "^6.7.1", + "@codemirror/view": "^6.43.7", + "@lezer/common": "^1.5.2", + "@lezer/highlight": "^1.2.3", + "@lezer/lr": "^1.4.10" } }, "node_modules/@humanfs/core": { @@ -4534,24 +4530,24 @@ } }, "node_modules/@lezer/common": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.3.tgz", - "integrity": "sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==", + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", "license": "MIT" }, "node_modules/@lezer/highlight": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.1.tgz", - "integrity": "sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", "license": "MIT", "dependencies": { - "@lezer/common": "^1.0.0" + "@lezer/common": "^1.3.0" } }, "node_modules/@lezer/lr": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.2.tgz", - "integrity": "sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==", + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", "license": "MIT", "dependencies": { "@lezer/common": "^1.0.0" @@ -9025,9 +9021,9 @@ } }, "node_modules/codemirror": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.1.tgz", - "integrity": "sha512-J8j+nZ+CdWmIeFIGXEFbFPtpiYacFMDR8GlHK3IyHQJMCaVRfGx9NT+Hxivv1ckLWPvNdZqndbr/7lVhrf/Svg==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", "license": "MIT", "dependencies": { "@codemirror/autocomplete": "^6.0.0", diff --git a/package.json b/package.json index 3da309fc..4bb08c7a 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,12 @@ "prepare": "husky" }, "dependencies": { - "@csound/browser": "^7.0.0-beta29", + "@codemirror/autocomplete": "^6.20.3", + "@codemirror/commands": "^6.10.4", + "@codemirror/language": "^6.12.4", + "@codemirror/state": "^6.7.1", + "@codemirror/view": "^6.43.8", + "@csound/browser": "^7.0.0-beta32", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@emotion/core": "^11.0.0", @@ -33,11 +38,13 @@ "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@hello-pangea/dnd": "^18.0.1", - "@hlolli/codemirror-lang-csound": "^1.0.0-alpha10", + "@hlolli/codemirror-lang-csound": "^1.0.0-alpha11", + "@lezer/common": "^1.5.2", "@mui/icons-material": "^6.2.0", "@mui/material": "^6.2.0", "@reduxjs/toolkit": "^2.5.0", "@vitejs/plugin-react": "^4.3.4", + "codemirror": "^6.0.2", "date-fns": "^4.1.0", "firebase-functions": "^6.4.0", "history": "5.3.0", @@ -101,7 +108,6 @@ "babel-loader": "9.2.1", "babel-plugin-named-asset-import": "^0.3.8", "camelcase": "^8.0.0", - "codemirror": "^6.0.1", "concurrently": "^9.1.0", "cross-env": "^7.0.3", "css-loader": "7.1.2", @@ -149,7 +155,6 @@ "resolutions": { "react-iframe-comm/react": "*", "react-iframe-comm/react-dom": "*", - "@babel/plugin-syntax-class-static-block@^7.0": "7.14.5", - "@hlolli/react-codemirror/codemirror": "^6.0.1" + "@babel/plugin-syntax-class-static-block@^7.0": "7.14.5" } } diff --git a/src/components/editor/utils.test.ts b/src/components/editor/utils.test.ts new file mode 100644 index 00000000..f2df5f3d --- /dev/null +++ b/src/components/editor/utils.test.ts @@ -0,0 +1,79 @@ +import { syntaxTree } from "@codemirror/language"; +import { EditorState } from "@codemirror/state"; +import { csoundMode } from "@hlolli/codemirror-lang-csound"; +import { describe, expect, it } from "vitest"; +import { findSurroundingContext } from "./utils"; + +const contextAt = ( + source: string, + fileType: "csd" | "orc" | "sco", + search: string +): string | undefined => { + const state = EditorState.create({ + doc: source, + extensions: [csoundMode({ fileType })] + }); + const position = source.indexOf(search); + const context = findSurroundingContext( + syntaxTree(state).cursorAt(position, 1) + ); + + return context && source.slice(context.from, context.to); +}; + +describe("findSurroundingContext", () => { + it("selects a whole instrument in an orchestra", () => { + const source = [ + "instr 1", + "a1 oscili 0.2, 440", + "out a1", + "endin", + "" + ].join("\n"); + + expect(contextAt(source, "orc", "oscili")).toBe(source.trimEnd()); + }); + + it("selects a whole legacy UDO", () => { + const source = [ + "opcode PassThrough, a, a", + "ain xin", + "xout ain", + "endop", + "" + ].join("\n"); + + expect(contextAt(source, "orc", "xout")).toBe(source.trimEnd()); + }); + + it("selects one top-level orchestra statement", () => { + const source = ["giValue init 1", 'prints "ready"', ""].join("\n"); + + expect(contextAt(source, "orc", "prints")).toBe('prints "ready"\n'); + }); + + it("selects one score statement", () => { + const source = ["f 1 0 1024 10 1", "i 1 0 1", ""].join("\n"); + + expect(contextAt(source, "sco", "i 1")).toBe("i 1 0 1\n"); + }); + + it("selects a whole instrument inside a CSD", () => { + const instrument = [ + "instr 1", + "a1 oscili 0.2, 440", + "out a1", + "endin" + ].join("\n"); + const source = [ + "", + "", + instrument, + "", + "", + "" + ].join("\n"); + + expect(contextAt(source, "csd", "oscili")).toBe(instrument); + }); +}); diff --git a/src/components/editor/utils.ts b/src/components/editor/utils.ts index 70cc049e..891d5f00 100644 --- a/src/components/editor/utils.ts +++ b/src/components/editor/utils.ts @@ -2,7 +2,7 @@ import { curry } from "ramda"; import { syntaxTree } from "@codemirror/language"; import { StateEffect, StateField, Transaction } from "@codemirror/state"; import { Decoration, DecorationSet, EditorView } from "@codemirror/view"; -import { TreeCursor } from "@lezer/common"; +import type { SyntaxNode, TreeCursor } from "@lezer/common"; import type { CsoundObj } from "@comp/csound/types"; const addBlinkSuccessMarks = StateEffect.define(); @@ -46,32 +46,33 @@ export const evalBlinkExtension = StateField.define({ provide: (f) => EditorView.decorations.from(f) }); -const findSurroundingContext = (view: EditorView, tree: TreeCursor) => { - const treeRoot = tree.node; - let maybeContext: any = treeRoot; - let lastContext: any = maybeContext; +const evaluableBlockNames = new Set(["InstrumentDefinition", "UdoDefinition"]); - while (maybeContext) { - if ( - ["InstrumentDeclaration", "UdoDeclaration"].includes( - maybeContext.type.name - ) - ) { - return maybeContext; +export const findSurroundingContext = ( + tree: TreeCursor +): SyntaxNode | undefined => { + let statement: SyntaxNode | undefined; + let node: SyntaxNode | null = tree.node; + + while (node) { + if (evaluableBlockNames.has(node.type.name)) { + return node; } - // if we find ourselves in global scope, check if the user wanted to evaluate a global statement + const parentName = node.parent?.type.name; if ( - maybeContext.type.name === "Program" && - ["OpcodeStatement", "CallbackExpression"].includes( - lastContext.type.name - ) + (node.type.name === "OrcStatement" && + parentName === "OrcStatements") || + (node.type.name === "ScoStatement" && + parentName === "ScoStatements") ) { - return lastContext; + statement = node; } - lastContext = maybeContext; - maybeContext = maybeContext.node.parent; + + node = node.parent; } + + return statement; }; const evalSelection = async ({ @@ -116,19 +117,21 @@ export const editorEvalCode = curry( view.state.selection.main.from !== view.state.selection.main.to; let selection; - let context: { from: number; to: number }; + let context: { from: number; to: number } | undefined; if (userHasSelection && !blockEval) { - selection = view.state.sliceDoc( - view.state.selection.main.from, - view.state.selection.main.to - ); + context = { + from: view.state.selection.main.from, + to: view.state.selection.main.to + }; + selection = view.state.sliceDoc(context.from, context.to); } else if (blockEval) { const treeRoot = syntaxTree(view.state).cursorAt( - view.state.selection.main.head + view.state.selection.main.head, + 1 ); - context = findSurroundingContext(view, treeRoot); + context = findSurroundingContext(treeRoot); if ( typeof context === "object" && @@ -145,7 +148,7 @@ export const editorEvalCode = curry( selection = view.state.sliceDoc(line.from, line.to); } - if (selection) { + if (selection && context) { evalSelection({ csound, documentType, evalString: selection }).then( (result: number) => { if (result === 0) { diff --git a/src/global.d.ts b/src/global.d.ts index 8c6f5954..48e9479c 100644 --- a/src/global.d.ts +++ b/src/global.d.ts @@ -4,7 +4,6 @@ declare module "d3-scale"; declare module "file-saver"; declare module "react-beforeunload"; declare module "react-iframe-comm"; -declare module "@hlolli/codemirror-lang-csound"; declare module "history" { export * from "history"; From 061c05c4fbb5ae7cab737a0447a68c78abad0bfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hl=C3=B6=C3=B0ver=20Sigur=C3=B0sson?= Date: Mon, 21 Sep 2026 09:07:38 +0300 Subject: [PATCH 2/4] refactor(editor): own Csound presentation and evaluation Use the upstream core language interface, keep rate styling and synopsis rendering in the IDE, and adapt syntax trees to plain evaluation ranges. Gate deployment on the upstream 1.0.3 release. --- docs/csound-language-migration.md | 69 ++++++ package-lock.json | 30 ++- package.json | 2 +- src/components/editor/csound-highlighting.ts | 216 ++++++++++++++++++ src/components/editor/csound-language.test.ts | 156 +++++++++++++ src/components/editor/csound-language.ts | 21 ++ src/components/editor/csound-synopsis.ts | 106 +++++++++ src/components/editor/editor.tsx | 10 +- src/components/editor/utils.test.ts | 158 ++++++++++++- src/components/editor/utils.ts | 140 +++++++----- src/styles/code-mirror-painter.tsx | 30 ++- 11 files changed, 846 insertions(+), 92 deletions(-) create mode 100644 docs/csound-language-migration.md create mode 100644 src/components/editor/csound-highlighting.ts create mode 100644 src/components/editor/csound-language.test.ts create mode 100644 src/components/editor/csound-language.ts create mode 100644 src/components/editor/csound-synopsis.ts diff --git a/docs/csound-language-migration.md b/docs/csound-language-migration.md new file mode 100644 index 00000000..e0c2a8ab --- /dev/null +++ b/docs/csound-language-migration.md @@ -0,0 +1,69 @@ +# Csound language migration + +This branch replaces `@hlolli/codemirror-lang-csound` with the core interface +of `@kunstmusik/codemirror-lang-csound`. + +## Ownership + +- `src/components/editor/csound-language.ts` composes `csound({ mode })` + with the IDE's extensions. It disables upstream's semantic colors and hover + UI, keeps completion, and sets the IDE's two-space indent. +- `csound-highlighting.ts` supplies the IDE's rate classes, using upstream's + semantic results to distinguish opcode calls from variables. +- `csound-synopsis.ts` renders the bottom panel from `getCsoundHoverInfo`. + The IDE owns the DOM, layout, and stale-result handling. +- `src/styles/code-mirror-painter.tsx` chooses all colors and panel styles. +- `utils.ts` owns context selection and execution. Its context helper adapts + the current grammar to plain `{ from, to, kind }` ranges. Block evaluation + sends score statements, including those inside CSDs, to `readScore`. + +The IDE does not use `/compat`. That temporary package entry only translates +old language-option names for other hosts. Neither it nor the core package +owns the IDE's themes, synopsis, keybindings, or evaluation policy. + +## Release gate + +The companion [language PR](https://github.com/kunstmusik/codemirror-lang-csound/pull/1) +prepares version 1.0.3. That version is not yet on npm, so do not merge or deploy +this IDE branch until it ships. Do not republish changed contents as 1.0.2. + +The manifest and lockfile require 1.0.3. The lock entry deliberately has no +registry URL or integrity hash yet: a local preview hash must not stand in for +the future npm release. Before merging, refresh the real registry metadata: + +```sh +npm install --package-lock-only --ignore-scripts @kunstmusik/codemirror-lang-csound@^1.0.3 +npm ci +npm run test:ci +npm run typecheck +npm run lint +npm run build +``` + +Commit that lockfile update after the upstream release. Until then, a normal +registry install is expected to fail because 1.0.3 does not exist yet. + +## Testing a local preview + +Build and pack the language package from its worktree: + +```sh +npm run build --workspace packages/codemirror-lang-csound +npm pack --workspace packages/codemirror-lang-csound --pack-destination /tmp +``` + +Then, from this IDE worktree, install the archive without changing its manifest +or lockfile: + +```sh +npm install --no-save --ignore-scripts /tmp/kunstmusik-codemirror-lang-csound-1.0.3.tgz +npm run test:ci +npm run typecheck +npm run lint +npm run build +``` + +The editor tests use the actual IDE composition and evaluation functions. They +cover modes, theme classes, whole-token `0dbfs`, synopsis updates, instrument +and UDO selection, top-level statements, score execution, explicit selections, +and stopped-engine behavior. Package tests cover language behavior separately. diff --git a/package-lock.json b/package-lock.json index 83afb9ef..508100fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@hello-pangea/dnd": "^18.0.1", - "@hlolli/codemirror-lang-csound": "^1.0.0-alpha11", + "@kunstmusik/codemirror-lang-csound": "^1.0.3", "@lezer/common": "^1.5.2", "@mui/icons-material": "^6.2.0", "@mui/material": "^6.2.0", @@ -4252,21 +4252,6 @@ "react-dom": "^18.0.0 || ^19.0.0" } }, - "node_modules/@hlolli/codemirror-lang-csound": { - "version": "1.0.0-alpha11", - "resolved": "https://registry.npmjs.org/@hlolli/codemirror-lang-csound/-/codemirror-lang-csound-1.0.0-alpha11.tgz", - "integrity": "sha512-rAxxRgc7sOEQw675Bfdh2N0sai7tImQ3a7J5Tqaux4g3ebRejHHe++YpwtmoHra7mIYC4ZofeUnBJ5eV6Fz15A==", - "license": "LGPL-2.0", - "dependencies": { - "@codemirror/autocomplete": "^6.20.3", - "@codemirror/language": "^6.12.4", - "@codemirror/state": "^6.7.1", - "@codemirror/view": "^6.43.7", - "@lezer/common": "^1.5.2", - "@lezer/highlight": "^1.2.3", - "@lezer/lr": "^1.4.10" - } - }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -4529,6 +4514,19 @@ "storybook-to-ghpages": "bin/storybook_to_ghpages" } }, + "node_modules/@kunstmusik/codemirror-lang-csound": { + "version": "1.0.3", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.10.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.2.0", + "@lezer/lr": "^1.4.0" + } + }, "node_modules/@lezer/common": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", diff --git a/package.json b/package.json index 4bb08c7a..a4ccd32c 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", "@hello-pangea/dnd": "^18.0.1", - "@hlolli/codemirror-lang-csound": "^1.0.0-alpha11", + "@kunstmusik/codemirror-lang-csound": "^1.0.3", "@lezer/common": "^1.5.2", "@mui/icons-material": "^6.2.0", "@mui/material": "^6.2.0", diff --git a/src/components/editor/csound-highlighting.ts b/src/components/editor/csound-highlighting.ts new file mode 100644 index 00000000..140dc075 --- /dev/null +++ b/src/components/editor/csound-highlighting.ts @@ -0,0 +1,216 @@ +import { syntaxTree } from "@codemirror/language"; +import type { Extension, Range } from "@codemirror/state"; +import { + Decoration, + EditorView, + ViewPlugin, + type DecorationSet, + type ViewUpdate +} from "@codemirror/view"; +import { analyzeCsoundSemanticLine } from "@kunstmusik/codemirror-lang-csound"; + +const identifierNodes = new Set([ + "Identifier", + "LegacyTypeIdentifier", + "TypedIdentifier", + "GlobalTypedIdentifier", + "ArrayIdentifier", + "TypedArrayIdentifier", + "GlobalTypedArrayIdentifier", + "HeaderIdentifier", + "PField" +]); +const headerNames = new Set([ + "sr", + "kr", + "ksmps", + "nchnls", + "nchnls_i", + "nchnls_hw", + "0dbfs" +]); +const definitionNodes = new Set([ + "instr", + "endin", + "opcode", + "endop", + "struct", + "declare", + "void", + "HashDefine", + "HashUndef" +]); +const controlNodes = new Set([ + "if", + "then", + "ithen", + "kthen", + "elseif", + "else", + "endif", + "fi", + "while", + "until", + "do", + "od", + "enduntil", + "for", + "in", + "switch", + "case", + "default", + "endsw", + "goto", + "igoto", + "kgoto", + "rigoto", + "reinit", + "break", + "continue", + "return", + "rireturn", + "xin", + "xout", + "HashIfdef", + "HashIfndef", + "HashElse", + "HashEnd" +]); + +function identifierClass( + text: string, + parent: string | undefined, + isOpcode: boolean +): string | null { + if (parent === "MemberAccessSegment") return null; + if (headerNames.has(text)) return "cm-csound-global-constant"; + if (/^p\d+$/.test(text)) return "cm-csound-p-field-var"; + if (parent === "LabelName") return "cm-csound-goto-token"; + if ( + parent === "FunctionCallee" || + parent === "ScoreFunctionCallee" || + parent === "UdoName" || + isOpcode + ) + return "cm-csound-opcode"; + + const explicitRate = /:([akiSf])(?:\[\])*$/.exec(text)?.[1]; + const rate = explicitRate ?? /^(?:g)?([akiSf])/.exec(text)?.[1] ?? "i"; + const global = + text.includes("@global:") || + (!text.includes(":") && /^g[akiSf]/.test(text)); + return `cm-csound-${rate.toLowerCase()}-rate-var${global ? " cm-csound-global-var" : ""}`; +} + +function decorations(view: EditorView, documentText: string): DecorationSet { + const ranges: Range[] = []; + const seen = new Set(); + const opcodePositions = new Set(); + for (const { from, to } of view.visibleRanges) { + syntaxTree(view.state).iterate({ + from, + to, + enter(node) { + // A token can straddle more than one visible range. + if (node.to <= from || node.from >= to) return; + if (node.name === "OrcGenericLine") { + const text = view.state.sliceDoc(node.from, node.to); + for (const span of analyzeCsoundSemanticLine(text, { + offset: node.from, + documentText + })) { + if ( + span.kind === "builtInOpcode" || + span.kind === "userOpcode" + ) + opcodePositions.add(span.from); + } + } + let className: string | null = null; + if (identifierNodes.has(node.name)) { + className = identifierClass( + view.state.sliceDoc(node.from, node.to), + node.node.parent?.name, + opcodePositions.has(node.from) + ); + } else if (definitionNodes.has(node.name)) + className = "cm-csound-define"; + else if (controlNodes.has(node.name)) + className = "cm-csound-control-flow"; + else if (node.name === "MacroUsageToken") + className = "cm-csound-macro-token"; + else if (node.name === "ScoreOpcode") + className = "cm-csound-opcode"; + else if (node.name === "String" || node.name === "RawString") + className = "cm-csound-s-rate-var"; + else if (node.name === "Number") className = "cm-csound-number"; + else if (node.name === "BooleanLiteral") + className = "cm-csound-boolean"; + else if (/^[()[\]{}]$/.test(node.name)) + className = "cm-csound-bracket"; + else if ( + /^(LineComment|BlockComment|LineContinuation)$/.test( + node.name + ) + ) + className = "cm-csound-comment"; + else if ( + /^Csd/.test(node.name) && + /(?:Tag|Open|Close|Csbeats)$/.test(node.name) + ) + className = "cm-csound-xml-tag"; + if (className && node.from < node.to) { + const key = node.from + ":" + node.to + ":" + node.name; + if (seen.has(key)) return false; + seen.add(key); + ranges.push( + Decoration.mark({ class: className }).range( + node.from, + node.to + ) + ); + return false; + } + } + }); + } + return Decoration.set(ranges, true); +} + +/** + * CSS classes for the Web IDE themes. + * Rates determine variable colors; globals carry an extra class. + */ +export function csoundRateHighlighting(): Extension { + return ViewPlugin.fromClass( + class { + decorations: DecorationSet; + documentText: string; + tree; + + constructor(view: EditorView) { + this.documentText = view.state.doc.toString(); + this.tree = syntaxTree(view.state); + this.decorations = decorations(view, this.documentText); + } + + update(update: ViewUpdate) { + if (update.docChanged) + this.documentText = update.state.doc.toString(); + const tree = syntaxTree(update.state); + if ( + update.docChanged || + update.viewportChanged || + tree !== this.tree + ) { + this.tree = tree; + this.decorations = decorations( + update.view, + this.documentText + ); + } + } + }, + { decorations: (plugin) => plugin.decorations } + ); +} diff --git a/src/components/editor/csound-language.test.ts b/src/components/editor/csound-language.test.ts new file mode 100644 index 00000000..1f585bd3 --- /dev/null +++ b/src/components/editor/csound-language.test.ts @@ -0,0 +1,156 @@ +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { indentUnit, syntaxTree } from "@codemirror/language"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { csoundEditorLanguage } from "./csound-language"; + +const views: EditorView[] = []; +afterEach(() => { + for (const view of views.splice(0)) { + view.destroy(); + } +}); + +function editor(doc: string, fileType = "orc", position = 0) { + const view = new EditorView({ + state: EditorState.create({ + doc, + selection: { anchor: position }, + extensions: [csoundEditorLanguage(fileType)] + }), + parent: document.body + }); + views.push(view); + return view; +} + +function marked(view: EditorView, className: string, token: string) { + return Array.from(view.contentDOM.querySelectorAll("." + className)).find( + (node) => node.textContent === token + ); +} + +describe("the IDE Csound adapter", () => { + it("chooses the core language and the IDE's indentation and completion", () => { + for (const [fileType, top] of [ + ["csd", "CsdFile"], + ["orc", "OrchestraFile"], + ["sco", "ScoreFile"], + ["udo", "OrchestraFile"] + ]) { + const state = EditorState.create({ + extensions: [csoundEditorLanguage(fileType)] + }); + expect(syntaxTree(state).topNode.name).toBe(top); + expect(state.facet(indentUnit)).toBe(" "); + expect(state.languageDataAt("autocomplete", 0)).toHaveLength(1); + } + }); + + it("supplies the CSS hooks used by the IDE themes, including the whole 0dbfs token", () => { + const view = editor( + [ + "0dbfs = 1", + "#define LEVEL #0.2#", + "gaSig init 0", + "instr 1", + " aSig oscili $LEVEL, 440", + " kRate = 1", + ' SText = "hi"', + " fSpec pvsanal aSig, 1024, 256, 1024, 1", + " kTime = p3", + " if kRate > 0 then", + " out aSig", + " endif", + "endin", + "" + ].join("\n") + ); + const expected = { + "cm-csound-define": "instr", + "cm-csound-control-flow": "if", + "cm-csound-opcode": "oscili", + "cm-csound-global-var": "gaSig", + "cm-csound-a-rate-var": "aSig", + "cm-csound-k-rate-var": "kRate", + "cm-csound-s-rate-var": "SText", + "cm-csound-f-rate-var": "fSpec", + "cm-csound-p-field-var": "p3", + "cm-csound-global-constant": "0dbfs", + "cm-csound-macro-token": "$LEVEL" + }; + for (const [className, token] of Object.entries(expected)) { + expect(marked(view, className, token), className).toBeDefined(); + } + view.dispatch({ changes: { from: 0, to: 5, insert: "nchnls" } }); + expect( + marked(view, "cm-csound-global-constant", "nchnls") + ).toBeDefined(); + expect(view.dom.querySelector(".cm-csoundBuiltinOpcode")).toBeNull(); + }); + + it("uses explicit types before legacy prefixes and skips member names", () => { + const view = editor( + 'voice@global:a init 0\ncounter:k = 1\ntext@global:S = "hi"\nvalue = point.field\n' + ); + expect( + marked(view, "cm-csound-global-var", "voice@global:a") + ).toBeDefined(); + expect( + marked(view, "cm-csound-a-rate-var", "voice@global:a") + ).toBeDefined(); + expect(marked(view, "cm-csound-k-rate-var", "counter:k")).toBeDefined(); + expect( + marked(view, "cm-csound-s-rate-var", "text@global:S") + ).toBeDefined(); + expect(marked(view, "cm-csound-f-rate-var", "field")).toBeUndefined(); + }); + + it("renders opcode help in its own bottom panel for both call styles", async () => { + for (const line of ["a1 oscili 0.2, 440", "a1 = oscili:a(0.2, 440)"]) { + const view = editor(line + "\n", "orc", line.indexOf("440")); + const panel = view.dom.querySelector( + ".cm-panels-bottom .cm-csound-synopsis" + ); + expect(panel?.getAttribute("role")).toBe("status"); + await vi.waitFor(() => { + expect( + panel?.querySelector(".cm-csound-opcode")?.textContent + ).toBe("oscili"); + expect(panel?.textContent).toContain("xamp"); + }); + } + }); + + it("updates UDO help on edits and clears stale results when the cursor moves", async () => { + const doc = + "opcode localPass(signal:a):a\n xout(signal)\nendop\naSig = localPass(0.2)\n\n"; + const position = doc.indexOf("0.2"); + const view = editor(doc, "orc", position); + const panel = view.dom.querySelector(".cm-csound-synopsis"); + await vi.waitFor(() => + expect(panel?.textContent).toContain("localPass") + ); + const call = doc.lastIndexOf("localPass"); + view.dispatch({ + changes: { + from: call, + to: call + "localPass".length, + insert: "unknownCall" + } + }); + await vi.waitFor(() => expect(panel?.textContent).toBe("")); + view.dispatch({ + changes: { + from: call, + to: call + "unknownCall".length, + insert: "localPass" + }, + selection: { anchor: position } + }); + view.dispatch({ selection: { anchor: view.state.doc.length } }); + await Promise.resolve(); + await Promise.resolve(); + expect(panel?.textContent).toBe(""); + }); +}); diff --git a/src/components/editor/csound-language.ts b/src/components/editor/csound-language.ts new file mode 100644 index 00000000..b05824bf --- /dev/null +++ b/src/components/editor/csound-language.ts @@ -0,0 +1,21 @@ +import { csound } from "@kunstmusik/codemirror-lang-csound"; +import { indentUnit } from "@codemirror/language"; +import type { Extension } from "@codemirror/state"; +import { csoundRateHighlighting } from "./csound-highlighting"; +import { csoundSynopsis } from "./csound-synopsis"; + +/** Compose language support with the IDE's presentation choices. */ +export function csoundEditorLanguage(fileType?: string): Extension { + const mode = + fileType === "csd" || !fileType + ? "csd" + : fileType === "sco" + ? "sco" + : "orc"; + return [ + csound({ mode, semanticHighlighting: false, hover: false }), + csoundRateHighlighting(), + csoundSynopsis(), + indentUnit.of(" ") + ]; +} diff --git a/src/components/editor/csound-synopsis.ts b/src/components/editor/csound-synopsis.ts new file mode 100644 index 00000000..47867b38 --- /dev/null +++ b/src/components/editor/csound-synopsis.ts @@ -0,0 +1,106 @@ +import { syntaxTree } from "@codemirror/language"; +import type { EditorState, Extension } from "@codemirror/state"; +import type { SyntaxNode } from "@lezer/common"; +import { showPanel } from "@codemirror/view"; +import { + getCsoundHoverInfo, + analyzeCsoundSemanticLine +} from "@kunstmusik/codemirror-lang-csound"; + +function opcodeAtSelection(state: EditorState): string | undefined { + let node: SyntaxNode | null = syntaxTree(state).resolveInner( + state.selection.main.head, + -1 + ); + while (node) { + if ( + node.name === "FunctionCallExpr" || + node.name === "ScoreFunctionCallExpr" + ) { + const callee = + node.getChild("FunctionCallee") ?? + node.getChild("ScoreFunctionCallee"); + if (callee) return state.sliceDoc(callee.from, callee.to).trim(); + } + if (node.name === "OrcGenericLine") { + const text = state.sliceDoc(node.from, node.to); + const span = analyzeCsoundSemanticLine(text, { + documentText: state.doc.toString() + }).find( + (span) => + span.kind === "builtInOpcode" || span.kind === "userOpcode" + ); + return span ? text.slice(span.from, span.to) : undefined; + } + node = node.parent; + } + return undefined; +} + +/** Cursor-driven opcode help in CodeMirror's bottom panel. */ +export function csoundSynopsis(): Extension { + return showPanel.of((view) => { + const dom = document.createElement("div"); + dom.className = "cm-csound-synopsis"; + dom.setAttribute("role", "status"); + dom.setAttribute("aria-live", "polite"); + let request = 0; + let destroyed = false; + + function refresh() { + const current = ++request; + const token = opcodeAtSelection(view.state); + dom.replaceChildren(); + if (!token) return; + const [name, rate] = token.split(":"); + void getCsoundHoverInfo(name, { + documentText: view.state.doc.toString() + }) + .then((info) => { + // The rich catalog loads on demand. Ignore a result for an old cursor. + if (destroyed || current !== request || !info) return; + const title = document.createElement("span"); + title.className = "cm-csound-opcode"; + title.textContent = info.name; + const signature = + info.signatures.find( + (signature) => rate && signature.outTypes === rate + ) ?? info.signatures[0]; + const syntax = + info.syntax?.find( + (line) => !rate || line.trim().startsWith(rate) + ) ?? info.syntax?.[0]; + const text = + syntax ?? + (signature + ? `${signature.outTypes === "(null)" ? "" : signature.outTypes + " "}${info.name} ${signature.inTypes === "(null)" ? "" : signature.inTypes}`.trim() + : info.name); + const detail = document.createElement("span"); + detail.textContent = ` — ${text}${info.shortDescription ? " · " + info.shortDescription : ""}`; + dom.replaceChildren(title, detail); + }) + .catch(() => { + if (!destroyed && current === request) + dom.replaceChildren(); + }); + } + + refresh(); + return { + dom, + top: false, + update(update) { + if ( + update.docChanged || + update.selectionSet || + syntaxTree(update.state) !== syntaxTree(update.startState) + ) + refresh(); + }, + destroy() { + destroyed = true; + request++; + } + }; + }); +} diff --git a/src/components/editor/editor.tsx b/src/components/editor/editor.tsx index d72026f6..37799eb9 100644 --- a/src/components/editor/editor.tsx +++ b/src/components/editor/editor.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useDispatch, useSelector } from "@root/store"; -import { csoundMode } from "@hlolli/codemirror-lang-csound"; +import { csoundEditorLanguage } from "./csound-language"; import { EditorView } from "codemirror"; import { crosshairCursor, @@ -142,13 +142,7 @@ const CodeEditor = ({ dropCursor(), EditorState.allowMultipleSelections.of(true), indentOnInput(), - csoundMode({ - fileType: - csoundFileType && - ((["sco", "orc", "csd"].includes(csoundFileType) - ? csoundFileType - : "orc") as "sco" | "orc" | "csd") - }), + csoundEditorLanguage(csoundFileType), keymap.of([ ...defaultKeymap.filter( (keyb) => diff --git a/src/components/editor/utils.test.ts b/src/components/editor/utils.test.ts index f2df5f3d..882067e2 100644 --- a/src/components/editor/utils.test.ts +++ b/src/components/editor/utils.test.ts @@ -1,8 +1,12 @@ -import { syntaxTree } from "@codemirror/language"; import { EditorState } from "@codemirror/state"; -import { csoundMode } from "@hlolli/codemirror-lang-csound"; -import { describe, expect, it } from "vitest"; -import { findSurroundingContext } from "./utils"; +import { EditorView } from "@codemirror/view"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { csoundEditorLanguage } from "./csound-language"; +import { + editorEvalCode, + evalBlinkExtension, + findSurroundingContext +} from "./utils"; const contextAt = ( source: string, @@ -11,17 +15,56 @@ const contextAt = ( ): string | undefined => { const state = EditorState.create({ doc: source, - extensions: [csoundMode({ fileType })] + extensions: [csoundEditorLanguage(fileType)] }); const position = source.indexOf(search); - const context = findSurroundingContext( - syntaxTree(state).cursorAt(position, 1) - ); + const context = findSurroundingContext(state, position); return context && source.slice(context.from, context.to); }; describe("findSurroundingContext", () => { + it("returns only a range and kind, not a parser node", () => { + const source = "instr 1\nout 0\nendin\n"; + const state = EditorState.create({ + doc: source, + extensions: [csoundEditorLanguage("orc")] + }); + expect(findSurroundingContext(state, source.indexOf("out"))).toEqual({ + from: 0, + to: source.trimEnd().length, + kind: "instrument" + }); + }); + + it("selects a multiline modern UDO, including from a nested conditional", () => { + const source = [ + "opcode tvöfalda(", + " gildi:i", + "):i", + " if gildi > 0 then", + " xout(gildi * 2)", + " endif", + "endop", + "" + ].join("\n"); + expect(contextAt(source, "orc", "xout")).toBe(source.trimEnd()); + }); + + it("selects a declaration and a top-level control block", () => { + const declaration = "declare split(signal:a):(a,a)\n"; + expect(contextAt(declaration, "orc", "split")).toBe(declaration); + const control = 'if true then\nprints "ready"\nendif\n'; + expect(contextAt(control, "orc", "prints")).toBe(control.trimEnd()); + }); + + it("selects a score statement inside a CSD but not options or XML", () => { + const source = + "\n\n-odac\n\n\ni 1 0 1\n\n\n"; + expect(contextAt(source, "csd", "i 1")).toBe("i 1 0 1\n"); + expect(contextAt(source, "csd", "-odac")).toBeUndefined(); + expect(contextAt(source, "csd", "")).toBeUndefined(); + }); it("selects a whole instrument in an orchestra", () => { const source = [ "instr 1", @@ -77,3 +120,102 @@ describe("findSurroundingContext", () => { expect(contextAt(source, "csd", "oscili")).toBe(instrument); }); }); + +describe("editorEvalCode", () => { + const views: EditorView[] = []; + afterEach(() => { + for (const view of views.splice(0)) view.destroy(); + vi.useRealTimers(); + }); + + function setup(doc: string, fileType: string, cursor: string) { + vi.useFakeTimers(); + const view = new EditorView({ + state: EditorState.create({ + doc, + selection: { anchor: doc.indexOf(cursor) }, + extensions: [csoundEditorLanguage(fileType), evalBlinkExtension] + }), + parent: document.body + }); + views.push(view); + const csound = { + evalCode: vi.fn().mockResolvedValue(0), + readScore: vi.fn().mockResolvedValue(0) + }; + return { view, csound }; + } + + it.each([ + ["orc", "instr 1\nout 0\nendin\n", "out", "instr 1\nout 0\nendin"], + [ + "udo", + "opcode PassThrough, a, a\nain xin\nxout ain\nendop\n", + "xout", + "opcode PassThrough, a, a\nain xin\nxout ain\nendop" + ], + [ + "orc", + "opcode Pass(signal:a):a\nxout(signal)\nendop\n", + "xout", + "opcode Pass(signal:a):a\nxout(signal)\nendop" + ], + [ + "orc", + 'giValue init 1\nprints "ready"\n', + "prints", + 'prints "ready"\n' + ], + [ + "csd", + "\n\ninstr 1\nout 0\nendin\n\n\n", + "out", + "instr 1\nout 0\nendin" + ] + ])( + "evaluates the selected %s block through evalCode", + async (mode, doc, cursor, expected) => { + const { view, csound } = setup(doc, mode, cursor); + editorEvalCode(csound, "playing", mode, view, true); + expect(csound.evalCode).toHaveBeenCalledWith(expected); + expect(csound.readScore).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(201); + } + ); + + it.each([ + ["sco", "f 1 0 1024 10 1\ni 1 0 1\n"], + [ + "csd", + "\n\ni 1 0 1\n\n\n" + ] + ])( + "sends a selected %s score statement through readScore", + async (mode, doc) => { + const { view, csound } = setup(doc, mode, "i 1"); + editorEvalCode(csound, "playing", mode, view, true); + expect(csound.readScore).toHaveBeenCalledWith("i 1 0 1\n"); + expect(csound.evalCode).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(201); + } + ); + + it("keeps explicit selection and line evaluation under host control", async () => { + const doc = "instr 1\nout 0\nendin\n"; + const { view, csound } = setup(doc, "orc", "out"); + view.dispatch({ selection: { anchor: 8, head: 13 } }); + editorEvalCode(csound, "playing", "orc", view, false); + expect(csound.evalCode).toHaveBeenLastCalledWith("out 0"); + view.dispatch({ selection: { anchor: 8 } }); + editorEvalCode(csound, "playing", "orc", view, false); + expect(csound.evalCode).toHaveBeenLastCalledWith("out 0"); + await vi.advanceTimersByTimeAsync(201); + }); + + it("does not evaluate while Csound is stopped", () => { + const { view, csound } = setup("out 0\n", "orc", "out"); + editorEvalCode(csound, "stopped", "orc", view, true); + expect(csound.evalCode).not.toHaveBeenCalled(); + expect(csound.readScore).not.toHaveBeenCalled(); + }); +}); diff --git a/src/components/editor/utils.ts b/src/components/editor/utils.ts index 891d5f00..74eca433 100644 --- a/src/components/editor/utils.ts +++ b/src/components/editor/utils.ts @@ -1,8 +1,13 @@ import { curry } from "ramda"; -import { syntaxTree } from "@codemirror/language"; -import { StateEffect, StateField, Transaction } from "@codemirror/state"; +import { ensureSyntaxTree, syntaxTree } from "@codemirror/language"; +import { + EditorState, + StateEffect, + StateField, + Transaction +} from "@codemirror/state"; import { Decoration, DecorationSet, EditorView } from "@codemirror/view"; -import type { SyntaxNode, TreeCursor } from "@lezer/common"; +import type { SyntaxNode } from "@lezer/common"; import type { CsoundObj } from "@comp/csound/types"; const addBlinkSuccessMarks = StateEffect.define(); @@ -46,17 +51,33 @@ export const evalBlinkExtension = StateField.define({ provide: (f) => EditorView.decorations.from(f) }); -const evaluableBlockNames = new Set(["InstrumentDefinition", "UdoDefinition"]); +export interface EvaluationContext { + from: number; + to: number; + kind: "instrument" | "udo" | "orchestra-statement" | "score-statement"; +} +// Keep grammar knowledge here; evaluation and UI code consume plain ranges. export const findSurroundingContext = ( - tree: TreeCursor -): SyntaxNode | undefined => { - let statement: SyntaxNode | undefined; - let node: SyntaxNode | null = tree.node; + state: EditorState, + position = state.selection.main.head +): EvaluationContext | undefined => { + const tree = + ensureSyntaxTree(state, state.doc.length, 100) ?? syntaxTree(state); + let statement: EvaluationContext | undefined; + let node: SyntaxNode | null = tree.resolveInner(position, 1); while (node) { - if (evaluableBlockNames.has(node.type.name)) { - return node; + if ( + node.name === "InstrumentDefinition" || + node.name === "UdoDefinition" + ) { + return { + from: node.from, + to: node.to, + kind: + node.name === "InstrumentDefinition" ? "instrument" : "udo" + }; } const parentName = node.parent?.type.name; @@ -66,7 +87,14 @@ export const findSurroundingContext = ( (node.type.name === "ScoStatement" && parentName === "ScoStatements") ) { - statement = node; + statement = { + from: node.from, + to: node.to, + kind: + node.name === "OrcStatement" + ? "orchestra-statement" + : "score-statement" + }; } node = node.parent; @@ -117,7 +145,9 @@ export const editorEvalCode = curry( view.state.selection.main.from !== view.state.selection.main.to; let selection; - let context: { from: number; to: number } | undefined; + let context: + | { from: number; to: number; kind?: EvaluationContext["kind"] } + | undefined; if (userHasSelection && !blockEval) { context = { @@ -126,12 +156,7 @@ export const editorEvalCode = curry( }; selection = view.state.sliceDoc(context.from, context.to); } else if (blockEval) { - const treeRoot = syntaxTree(view.state).cursorAt( - view.state.selection.main.head, - 1 - ); - - context = findSurroundingContext(treeRoot); + context = findSurroundingContext(view.state); if ( typeof context === "object" && @@ -149,46 +174,47 @@ export const editorEvalCode = curry( } if (selection && context) { - evalSelection({ csound, documentType, evalString: selection }).then( - (result: number) => { - if (result === 0) { - view.dispatch({ - effects: addBlinkSuccessMarks.of([ - blinkSuccessMarks.range( - context.from, - context.to - ) - ] as any) - }); - } else { - view.dispatch({ - effects: addBlinkErrorMarks.of([ - blinkErrorMarks.range(context.from, context.to) - ] as any) - }); - } - - setTimeout( - () => - result === 0 - ? view.dispatch({ - effects: removeBlinkSuccessMarks.of( - ((from: number, to: number) => - to <= context.from || - from >= context.to) as any - ) - }) - : view.dispatch({ - effects: removeBlinkErrorMarks.of( - ((from: number, to: number) => - to <= context.from || - from >= context.to) as any - ) - }), - 200 - ); + const evaluationType = + context.kind === "score-statement" ? "sco" : documentType; + evalSelection({ + csound, + documentType: evaluationType, + evalString: selection + }).then((result: number) => { + if (result === 0) { + view.dispatch({ + effects: addBlinkSuccessMarks.of([ + blinkSuccessMarks.range(context.from, context.to) + ] as any) + }); + } else { + view.dispatch({ + effects: addBlinkErrorMarks.of([ + blinkErrorMarks.range(context.from, context.to) + ] as any) + }); } - ); + + setTimeout( + () => + result === 0 + ? view.dispatch({ + effects: removeBlinkSuccessMarks.of( + ((from: number, to: number) => + to <= context.from || + from >= context.to) as any + ) + }) + : view.dispatch({ + effects: removeBlinkErrorMarks.of( + ((from: number, to: number) => + to <= context.from || + from >= context.to) as any + ) + }), + 200 + ); + }); } } ); diff --git a/src/styles/code-mirror-painter.tsx b/src/styles/code-mirror-painter.tsx index a8b2efdb..dccf5e22 100644 --- a/src/styles/code-mirror-painter.tsx +++ b/src/styles/code-mirror-painter.tsx @@ -47,11 +47,18 @@ export const editorStyle = (theme: Theme): SerializedStyles => css` white-space: nowrap; color: ${theme.textColor}; font-family: ${theme.font.monospace}; - font-size: "14px"; - user-select: "none"; + font-size: 14px; + user-select: none; background-color: ${theme.gutterBackground}; } + .cm-csound-synopsis { + overflow: hidden; + text-overflow: ellipsis; + padding: 2px 6px; + min-height: 1.4em; + } + .cm-lineNumbers { fontfamily: ${theme.font.monospace}; fontsize: "16px"; @@ -64,6 +71,25 @@ export const editorStyle = (theme: Theme): SerializedStyles => css` color: ${theme.aRateVar}!important; } + .cm-csound-i-rate-var, + .cm-csound-number { + color: ${theme.iRateVar}!important; + } + + .cm-csound-comment { + color: ${theme.comment}!important; + } + + .cm-csound-bracket { + color: ${theme.bracket}!important; + } + + .cm-csound-boolean, + .cm-csound-xml-tag, + .cm-csound-goto-token { + color: ${theme.keyword}!important; + } + .cm-csound-p-field-var { color: ${theme.pField}!important; font-weight: 600; From 4b73d0d966f8038320b3f4f8f1791b9a8d5092f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hl=C3=B6=C3=B0ver=20Sigur=C3=B0sson?= Date: Mon, 21 Sep 2026 10:19:41 +0300 Subject: [PATCH 3/4] refactor(editor): use generated Csound syntax names --- docs/csound-language-migration.md | 3 + src/components/editor/csound-highlighting.ts | 59 ++++++++++---------- src/components/editor/csound-synopsis.ts | 11 ++-- src/components/editor/utils.ts | 19 ++++--- 4 files changed, 49 insertions(+), 43 deletions(-) diff --git a/docs/csound-language-migration.md b/docs/csound-language-migration.md index e0c2a8ab..0738ded3 100644 --- a/docs/csound-language-migration.md +++ b/docs/csound-language-migration.md @@ -10,6 +10,9 @@ of `@kunstmusik/codemirror-lang-csound`. UI, keeps completion, and sets the IDE's two-space indent. - `csound-highlighting.ts` supplies the IDE's rate classes, using upstream's semantic results to distinguish opcode calls from variables. + Its identifier group comes from upstream's `/syntax` entry. Checked names + and sets also cover the synopsis and context adapters, so a renamed node + fails typechecking instead of silently losing behavior. - `csound-synopsis.ts` renders the bottom panel from `getCsoundHoverInfo`. The IDE owns the DOM, layout, and stale-result handling. - `src/styles/code-mirror-painter.tsx` chooses all colors and panel styles. diff --git a/src/components/editor/csound-highlighting.ts b/src/components/editor/csound-highlighting.ts index 140dc075..ba447ad7 100644 --- a/src/components/editor/csound-highlighting.ts +++ b/src/components/editor/csound-highlighting.ts @@ -1,3 +1,8 @@ +import { + csoundNodeNames as nodes, + csoundNodeGroups, + csoundNodeSet +} from "@kunstmusik/codemirror-lang-csound/syntax"; import { syntaxTree } from "@codemirror/language"; import type { Extension, Range } from "@codemirror/state"; import { @@ -9,17 +14,7 @@ import { } from "@codemirror/view"; import { analyzeCsoundSemanticLine } from "@kunstmusik/codemirror-lang-csound"; -const identifierNodes = new Set([ - "Identifier", - "LegacyTypeIdentifier", - "TypedIdentifier", - "GlobalTypedIdentifier", - "ArrayIdentifier", - "TypedArrayIdentifier", - "GlobalTypedArrayIdentifier", - "HeaderIdentifier", - "PField" -]); +const identifierNodes = csoundNodeSet(csoundNodeGroups.CsoundIdentifier); const headerNames = new Set([ "sr", "kr", @@ -29,7 +24,7 @@ const headerNames = new Set([ "nchnls_hw", "0dbfs" ]); -const definitionNodes = new Set([ +const definitionNodes = csoundNodeSet([ "instr", "endin", "opcode", @@ -37,10 +32,10 @@ const definitionNodes = new Set([ "struct", "declare", "void", - "HashDefine", - "HashUndef" + nodes.HashDefine, + nodes.HashUndef ]); -const controlNodes = new Set([ +const controlNodes = csoundNodeSet([ "if", "then", "ithen", @@ -71,10 +66,10 @@ const controlNodes = new Set([ "rireturn", "xin", "xout", - "HashIfdef", - "HashIfndef", - "HashElse", - "HashEnd" + nodes.HashIfdef, + nodes.HashIfndef, + nodes.HashElse, + nodes.HashEnd ]); function identifierClass( @@ -82,14 +77,14 @@ function identifierClass( parent: string | undefined, isOpcode: boolean ): string | null { - if (parent === "MemberAccessSegment") return null; + if (parent === nodes.MemberAccessSegment) return null; if (headerNames.has(text)) return "cm-csound-global-constant"; if (/^p\d+$/.test(text)) return "cm-csound-p-field-var"; - if (parent === "LabelName") return "cm-csound-goto-token"; + if (parent === nodes.LabelName) return "cm-csound-goto-token"; if ( - parent === "FunctionCallee" || - parent === "ScoreFunctionCallee" || - parent === "UdoName" || + parent === nodes.FunctionCallee || + parent === nodes.ScoreFunctionCallee || + parent === nodes.UdoName || isOpcode ) return "cm-csound-opcode"; @@ -113,7 +108,7 @@ function decorations(view: EditorView, documentText: string): DecorationSet { enter(node) { // A token can straddle more than one visible range. if (node.to <= from || node.from >= to) return; - if (node.name === "OrcGenericLine") { + if (node.name === nodes.OrcGenericLine) { const text = view.state.sliceDoc(node.from, node.to); for (const span of analyzeCsoundSemanticLine(text, { offset: node.from, @@ -137,14 +132,18 @@ function decorations(view: EditorView, documentText: string): DecorationSet { className = "cm-csound-define"; else if (controlNodes.has(node.name)) className = "cm-csound-control-flow"; - else if (node.name === "MacroUsageToken") + else if (node.name === nodes.MacroUsageToken) className = "cm-csound-macro-token"; - else if (node.name === "ScoreOpcode") + else if (node.name === nodes.ScoreOpcode) className = "cm-csound-opcode"; - else if (node.name === "String" || node.name === "RawString") + else if ( + node.name === nodes.String || + node.name === nodes.RawString + ) className = "cm-csound-s-rate-var"; - else if (node.name === "Number") className = "cm-csound-number"; - else if (node.name === "BooleanLiteral") + else if (node.name === nodes.Number) + className = "cm-csound-number"; + else if (node.name === nodes.BooleanLiteral) className = "cm-csound-boolean"; else if (/^[()[\]{}]$/.test(node.name)) className = "cm-csound-bracket"; diff --git a/src/components/editor/csound-synopsis.ts b/src/components/editor/csound-synopsis.ts index 47867b38..489b69a6 100644 --- a/src/components/editor/csound-synopsis.ts +++ b/src/components/editor/csound-synopsis.ts @@ -1,3 +1,4 @@ +import { csoundNodeNames as nodes } from "@kunstmusik/codemirror-lang-csound/syntax"; import { syntaxTree } from "@codemirror/language"; import type { EditorState, Extension } from "@codemirror/state"; import type { SyntaxNode } from "@lezer/common"; @@ -14,15 +15,15 @@ function opcodeAtSelection(state: EditorState): string | undefined { ); while (node) { if ( - node.name === "FunctionCallExpr" || - node.name === "ScoreFunctionCallExpr" + node.name === nodes.FunctionCallExpr || + node.name === nodes.ScoreFunctionCallExpr ) { const callee = - node.getChild("FunctionCallee") ?? - node.getChild("ScoreFunctionCallee"); + node.getChild(nodes.FunctionCallee) ?? + node.getChild(nodes.ScoreFunctionCallee); if (callee) return state.sliceDoc(callee.from, callee.to).trim(); } - if (node.name === "OrcGenericLine") { + if (node.name === nodes.OrcGenericLine) { const text = state.sliceDoc(node.from, node.to); const span = analyzeCsoundSemanticLine(text, { documentText: state.doc.toString() diff --git a/src/components/editor/utils.ts b/src/components/editor/utils.ts index 74eca433..a0556d38 100644 --- a/src/components/editor/utils.ts +++ b/src/components/editor/utils.ts @@ -1,3 +1,4 @@ +import { csoundNodeNames as nodes } from "@kunstmusik/codemirror-lang-csound/syntax"; import { curry } from "ramda"; import { ensureSyntaxTree, syntaxTree } from "@codemirror/language"; import { @@ -69,29 +70,31 @@ export const findSurroundingContext = ( while (node) { if ( - node.name === "InstrumentDefinition" || - node.name === "UdoDefinition" + node.name === nodes.InstrumentDefinition || + node.name === nodes.UdoDefinition ) { return { from: node.from, to: node.to, kind: - node.name === "InstrumentDefinition" ? "instrument" : "udo" + node.name === nodes.InstrumentDefinition + ? "instrument" + : "udo" }; } const parentName = node.parent?.type.name; if ( - (node.type.name === "OrcStatement" && - parentName === "OrcStatements") || - (node.type.name === "ScoStatement" && - parentName === "ScoStatements") + (node.type.name === nodes.OrcStatement && + parentName === nodes.OrcStatements) || + (node.type.name === nodes.ScoStatement && + parentName === nodes.ScoStatements) ) { statement = { from: node.from, to: node.to, kind: - node.name === "OrcStatement" + node.name === nodes.OrcStatement ? "orchestra-statement" : "score-statement" }; From 369cca3a83b58df582fe06c80bf2e9e794ae5313 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hl=C3=B6=C3=B0ver=20Sigur=C3=B0sson?= Date: Mon, 21 Sep 2026 17:39:43 +0300 Subject: [PATCH 4/4] docs: remove migration guide --- docs/csound-language-migration.md | 72 ------------------------------- 1 file changed, 72 deletions(-) delete mode 100644 docs/csound-language-migration.md diff --git a/docs/csound-language-migration.md b/docs/csound-language-migration.md deleted file mode 100644 index 0738ded3..00000000 --- a/docs/csound-language-migration.md +++ /dev/null @@ -1,72 +0,0 @@ -# Csound language migration - -This branch replaces `@hlolli/codemirror-lang-csound` with the core interface -of `@kunstmusik/codemirror-lang-csound`. - -## Ownership - -- `src/components/editor/csound-language.ts` composes `csound({ mode })` - with the IDE's extensions. It disables upstream's semantic colors and hover - UI, keeps completion, and sets the IDE's two-space indent. -- `csound-highlighting.ts` supplies the IDE's rate classes, using upstream's - semantic results to distinguish opcode calls from variables. - Its identifier group comes from upstream's `/syntax` entry. Checked names - and sets also cover the synopsis and context adapters, so a renamed node - fails typechecking instead of silently losing behavior. -- `csound-synopsis.ts` renders the bottom panel from `getCsoundHoverInfo`. - The IDE owns the DOM, layout, and stale-result handling. -- `src/styles/code-mirror-painter.tsx` chooses all colors and panel styles. -- `utils.ts` owns context selection and execution. Its context helper adapts - the current grammar to plain `{ from, to, kind }` ranges. Block evaluation - sends score statements, including those inside CSDs, to `readScore`. - -The IDE does not use `/compat`. That temporary package entry only translates -old language-option names for other hosts. Neither it nor the core package -owns the IDE's themes, synopsis, keybindings, or evaluation policy. - -## Release gate - -The companion [language PR](https://github.com/kunstmusik/codemirror-lang-csound/pull/1) -prepares version 1.0.3. That version is not yet on npm, so do not merge or deploy -this IDE branch until it ships. Do not republish changed contents as 1.0.2. - -The manifest and lockfile require 1.0.3. The lock entry deliberately has no -registry URL or integrity hash yet: a local preview hash must not stand in for -the future npm release. Before merging, refresh the real registry metadata: - -```sh -npm install --package-lock-only --ignore-scripts @kunstmusik/codemirror-lang-csound@^1.0.3 -npm ci -npm run test:ci -npm run typecheck -npm run lint -npm run build -``` - -Commit that lockfile update after the upstream release. Until then, a normal -registry install is expected to fail because 1.0.3 does not exist yet. - -## Testing a local preview - -Build and pack the language package from its worktree: - -```sh -npm run build --workspace packages/codemirror-lang-csound -npm pack --workspace packages/codemirror-lang-csound --pack-destination /tmp -``` - -Then, from this IDE worktree, install the archive without changing its manifest -or lockfile: - -```sh -npm install --no-save --ignore-scripts /tmp/kunstmusik-codemirror-lang-csound-1.0.3.tgz -npm run test:ci -npm run typecheck -npm run lint -npm run build -``` - -The editor tests use the actual IDE composition and evaluation functions. They -cover modes, theme classes, whole-token `0dbfs`, synopsis updates, instrument -and UDO selection, top-level statements, score execution, explicit selections, -and stopped-engine behavior. Package tests cover language behavior separately.