Skip to content

SonicPiEditorExtensionWorkout.kt

src/jvmMain/kotlin/afield/examples/sonicpi/SonicPiEditorExtensionWorkout.kt · 289 lines

package afield.examples.sonicpi

import afield.audio.synth.SonicPiEditorExtension
import afield.media.image.util.NodeTool
import java.nio.file.Files
import kotlin.io.path.writeText

FIELD_TEST id: sonicpi.editor-extension.workout runner: jvm-main entrypoint: afield.examples.sonicpi.SonicPiEditorExtensionWorkout tags: [smoke, examples, sonicpi, monaco, language-extension] timeoutSec: 60 expect: exitCode: 0 stdoutContains: - "sonicpi editor extension workout passed"

object SonicPiEditorExtensionWorkout

Runs the shipped sonicpi.js under node against the shipped vocabulary.

The vocabulary is loaded from the real generated sonicpi-vocabulary.json rather than a fixture, which makes this a contract test between the generator and the extension: if generate_sonic_pi_vocabulary.py ever changes shape — renames optDocs, stops grouping samples — the completions here go quiet and this fails, instead of the editor quietly offering nothing.

fun main(args: Array<String>)

    @JvmStatic
    fun main(args: Array<String>) {
        check(runTests()) { "sonicpi editor extension workout failed" }
    }

fun runTests(): Boolean

body — 255 lines, from line 34
    fun runTests(): Boolean {
        val js = SonicPiEditorExtension.javascript()
        require("FieldLanguageExtensions.register" in js)
        require("#sonicpi" in js)
        require("updateEmbeddedHighlight" in js)
        require("buildContextPayload" in js)
        require("provideCompletionItems" in js)
        require("registerSonicPiLanguage" in js)
        require("setLanguageConfiguration" in js)
        require("sonicpi-vocabulary.json" in js)

        val vocabulary = SonicPiEditorExtension.vocabularyJson()
        require("\"fns\"" in vocabulary) { "the vocabulary has no fns" }
        require("\"sampleGroups\"" in vocabulary) { "the vocabulary has no sample groups" }

        runNodeProbe(js, vocabulary)
        println("sonicpi editor extension workout passed")
        return true
    }

    private fun runNodeProbe(extensionSource: String, vocabularyJson: String) {
        val dir = Files.createTempDirectory("sonicpi-editor-extension-")
        val extensionFile = dir.resolve("sonicpi-extension.js")
        val vocabularyFile = dir.resolve("sonicpi-vocabulary.json")
        val probeFile = dir.resolve("probe.js")
        extensionFile.writeText(extensionSource)
        vocabularyFile.writeText(vocabularyJson)
        probeFile.writeText(nodeProbeSource())

        val node = NodeTool.resolveNode()
            ?: error("sonicpi editor-extension workout needs node: ${NodeTool.unavailableMessage()}")

        val process = ProcessBuilder(node, probeFile.toString(), extensionFile.toString(), vocabularyFile.toString())
            .redirectErrorStream(true)
            .start()
        val output = process.inputStream.bufferedReader().readText()
        val exit = process.waitFor()
        require(exit == 0) { "node probe failed with exit $exit\n$output" }
        require("sonicpi node probe passed" in output) { output }
        output.lineSequence().filter { it.startsWith("probe:") }.forEach { println(it) }
    }

    private fun nodeProbeSource(): String = """
        const fs = require("fs");
        const source = fs.readFileSync(process.argv[2], "utf8");
        const vocabulary = JSON.parse(fs.readFileSync(process.argv[3], "utf8"));
        const registered = [];
        class Range {
          constructor(startLineNumber, startColumn, endLineNumber, endColumn) {
            this.startLineNumber = startLineNumber;
            this.startColumn = startColumn;
            this.endLineNumber = endLineNumber;
            this.endColumn = endColumn;
          }
        }
        global.window = {
          FieldLanguageExtensions: {
            register(extension) { registered.push(extension); return extension; }
          },
          globalMonaco: {
            Range,
            languages: {
              CompletionItemKind: { Function: 1, Property: 2, EnumMember: 3, Keyword: 4 }
            },
            editor: { onDidChangeModelLanguage() {} }
          }
        };
        global.setTimeout = (fn) => { fn(); return 0; };
        // No fetch: the extension must fall back cleanly, and the probe injects the real
        // vocabulary itself so this tests the shipped JSON rather than a fixture.
        global.fetch = undefined;
        eval(source);

        if (registered.length !== 1) throw new Error("expected one registered extension, got " + registered.length);
        const extension = registered[0];
        if (extension.id !== "sonicpi") throw new Error("wrong extension id " + extension.id);

        const proof = global.window.__sonicpiExtensionProof;
        if (!proof) throw new Error("no proof object");
        proof.indexVocabulary(vocabulary);
        if (!proof.vocabulary.loaded) throw new Error("vocabulary did not index");
        if (proof.vocabulary.fns.size < 100) throw new Error("only " + proof.vocabulary.fns.size + " fns indexed");
        if (!proof.vocabulary.fns.has("live_loop")) throw new Error("no live_loop in the vocabulary");
        if (!proof.vocabulary.synths.has("dsaw")) throw new Error("no dsaw synth");
        if (!proof.vocabulary.fx.has("reverb")) throw new Error("no reverb fx");
        if (!proof.vocabulary.samples.has("bd_haus")) throw new Error("no bd_haus sample");
        console.log("probe: indexed " + proof.vocabulary.fns.size + " fns, " +
          proof.vocabulary.synths.size + " synths, " + proof.vocabulary.fx.size + " fx, " +
          proof.vocabulary.samples.size + " samples (Sonic Pi " + proof.vocabulary.version + ")");

        const text = [
          'import afield.audio.synth.*',
          'sonicPi(${"\"\"\""}',
          '#sonicpi',
          '# an ordinary comment',
          'live_loop :bikes do',
          '  use_synth :dsaw',
          '  with_fx :reverb, room: 0.5 do',
          '    play :e2, amp: 0.4, release: 8',
          '    sample :bd_haus',
          '  end',
          '  sleep 8',
          'end',
          '${"\"\"\""})'
        ].join("\n");

        function positionAt(offset) {
          const before = text.slice(0, offset).split("\n");
          return { lineNumber: before.length, column: before[before.length - 1].length + 1 };
        }
        function offsetAt(position) {
          const lines = text.split("\n");
          let offset = 0;
          for (let i = 0; i < position.lineNumber - 1; i++) offset += lines[i].length + 1;
          return offset + position.column - 1;
        }
        const model = {
          getLanguageId: () => "kotlin",
          getValue: () => text,
          getVersionId: () => 7,
          getPositionAt: positionAt,
          getOffsetAt: offsetAt,
          getLineContent: (n) => text.split("\n")[n - 1],
          getWordUntilPosition: (position) => {
            const line = text.split("\n")[position.lineNumber - 1].slice(0, position.column - 1);
            const m = /[A-Za-z_][A-Za-z0-9_?!]*$/.exec(line);
            const word = m ? m[0] : "";
            return { word, startColumn: position.column - word.length, endColumn: position.column };
          }
        };

        // --- region detection ---
        const regions = proof.findEmbeddedRegions(model);
        if (regions.length !== 1) throw new Error("expected one #sonicpi region, got " + regions.length);
        const region = regions[0];
        if (!/^\s*#sonicpi/.test(region.source)) {
          throw new Error("the header must stay in the region source (Sonic Pi counts it as line 1)");
        }
        if (text.slice(region.headerStart, region.headerEnd) !== "#sonicpi") {
          throw new Error("header offsets wrong: " + JSON.stringify(text.slice(region.headerStart, region.headerEnd)));
        }
        // The invariant the error marker depends on: a line's number inside the region equals its
        // line number in the box. Sonic Pi reports errors against the source it was handed, so if
        // these two ever diverge every marker lands on the wrong row. It holds because the opening
        // triple quote ends its line, so the region starts with a newline and its line 1 is the
        // box's line 1.
        const lineInRegion = (offset) => region.source.slice(0, offset - region.bodyStart).split("\n").length;
        const lineInModel = (offset) => positionAt(offset).lineNumber;
        const offsets = ["#sonicpi", "use_synth", "play :e2", "sleep 8"].map((needle) => {
          const at = text.indexOf(needle);
          if (at < 0) throw new Error("no " + needle + " in the probe source");
          return { needle, delta: lineInModel(at) - lineInRegion(at), boxLine: lineInModel(at) };
        });
        const deltas = new Set(offsets.map((o) => o.delta));
        if (deltas.size !== 1) {
          throw new Error("region-to-box line offset drifts: " + JSON.stringify(offsets));
        }
        const [delta] = [...deltas];
        // The offset is the line the opening triple quote sits on, minus one. It is 0 only when the
        // sonicPi( call is the box's first line, which is why the Kotlin side infers it rather than
        // defaulting it: an `import` above the call shifts every error marker by one.
        const expectedDelta = lineInModel(region.bodyStart) - 1;
        if (delta !== expectedDelta) {
          throw new Error("offset " + delta + " does not match the opening line " + expectedDelta);
        }
        console.log("probe: region-to-box line offset is a constant " + delta +
          " (" + offsets.map((o) => o.needle + "@" + o.boxLine).join(", ") + ")");

        // --- tokenisation and classification ---
        const tokens = proof.tokenize(region.source, region.bodyStart);
        const classOf = (needle, kind) => {
          const token = tokens.find((t) => t.text === needle && (!kind || t.kind === kind));
          if (!token) throw new Error("no token " + needle + (kind ? " of kind " + kind : ""));
          return proof.classForToken(token);
        };
        const expect = (needle, kind, expected) => {
          const got = classOf(needle, kind);
          if (got !== expected) throw new Error(needle + " classified " + got + ", expected " + expected);
        };
        expect("live_loop", "word", "afield-stacksl-token-keyword");
        expect("play", "word", "afield-stacksl-token-keyword");
        expect("do", "word", "afield-stacksl-token-control");
        expect("amp:", "opt", "afield-stacksl-token-operator");
        expect(":dsaw", "symbol", "afield-stacksl-token-creative");
        expect(":reverb", "symbol", "afield-stacksl-token-creative");
        expect(":bd_haus", "symbol", "afield-stacksl-token-creative");
        expect(":bikes", "symbol", "afield-stacksl-token-constant");
        expect("8", "number", "afield-stacksl-token-number");
        if (!tokens.some((t) => t.kind === "comment" && t.text.includes("ordinary comment"))) {
          throw new Error("the # comment was not tokenised as a comment");
        }
        console.log("probe: :dsaw/:reverb/:bd_haus read as sound names, :bikes as a plain symbol");

        // --- completions ---
        const bareLine = text.split("\n").findIndex((l) => l.includes("sleep 8")) + 1;
        const bare = proof.provideCompletionItems(
          { getModel: () => model, getPosition: () => ({ lineNumber: bareLine, column: 8 }) },
          model,
          { lineNumber: bareLine, column: 8 }
        );
        if (!bare || !bare.suggestions.length) throw new Error("no completions on a bare line");
        if (!bare.suggestions.some((s) => s.label === "live_loop")) throw new Error("live_loop not offered");

        // `use_synth :` must offer synths, not every symbol in Sonic Pi.
        const synthLine = text.split("\n").findIndex((l) => l.includes("use_synth")) + 1;
        const synthCol = text.split("\n")[synthLine - 1].indexOf(":dsaw") + 3;
        const synthSuggest = proof.provideCompletionItems(
          { getModel: () => model, getPosition: () => ({ lineNumber: synthLine, column: synthCol }) },
          model,
          { lineNumber: synthLine, column: synthCol }
        );
        const synthLabels = (synthSuggest ? synthSuggest.suggestions : []).map((s) => s.label);
        if (!synthLabels.includes(":dsaw")) throw new Error("use_synth : did not offer :dsaw");
        if (synthLabels.includes(":bd_haus")) {
          throw new Error("use_synth : offered a sample name, so the domain filter is not working");
        }
        console.log("probe: use_synth : offered " + synthLabels.length + " synths and no samples");

        // Opts of the function being called come first.
        const playLine = text.split("\n").findIndex((l) => l.includes("play :e2")) + 1;
        // A column where an opt is being typed, not one inside `:e2` — a caret after a colon is a
        // symbol position and correctly gets synth/sample names instead.
        const optCol = text.split("\n")[playLine - 1].indexOf("release:") + 2;
        const playSuggest = proof.provideCompletionItems(
          { getModel: () => model, getPosition: () => ({ lineNumber: playLine, column: optCol }) },
          model,
          { lineNumber: playLine, column: optCol }
        );
        const optLabels = (playSuggest ? playSuggest.suggestions : []).filter((s) => s.kind === 2).map((s) => s.label);
        if (!optLabels.includes("amp:")) throw new Error("play's amp: opt not offered, got " + optLabels.slice(0, 8));
        if (!optLabels.includes("release:")) throw new Error("play's release: opt not offered");
        console.log("probe: play offered " + optLabels.length + " of its own opts, including amp: and release:");

        // --- context badge ---
        const badgeCol = text.split("\n")[playLine - 1].indexOf("play") + 2;
        const payload = proof.buildContextPayload(
          { getModel: () => model, getPosition: () => ({ lineNumber: playLine, column: badgeCol }) },
          null,
          { contextKey: "probe" }
        );
        if (!payload || !payload.html.includes("Sonic Pi")) throw new Error("no Sonic Pi badge");
        if (!payload.html.includes("play")) throw new Error("badge did not name play: " + payload.html);
        console.log("probe: badge = " + payload.html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim());

        // A caret outside any #sonicpi region must not be claimed by this extension.
        const outside = proof.buildContextPayload(
          { getModel: () => model, getPosition: () => ({ lineNumber: 1, column: 3 }) },
          null,
          { contextKey: "probe" }
        );
        if (outside) throw new Error("claimed a caret on the Kotlin import line");

        console.log("sonicpi node probe passed");
    """.trimIndent()
}