Skip to content

DatalogEditorExtensionWorkout.kt

src/jvmMain/kotlin/afield/examples/datalog/DatalogEditorExtensionWorkout.kt · 377 lines

package afield.examples.datalog

import afield.data.query.datalog.DatalogEditorExtension
import afield.data.query.datalog.DatalogExecution
import afield.data.query.datalog.DatalogParser
import afield.data.query.datalog.dlBoxRelationSchema
import afield.media.image.util.NodeTool
import java.nio.file.Files
import kotlin.io.path.writeText

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

object DatalogEditorExtensionWorkout

Runs datalog.js for real, under node, against a mock Monaco.

A node probe rather than a unit test of a Kotlin mirror, for the reason the Petri workout gives: it exercises the exact file that ships — DatalogEditorExtension.javascript reads the same resource the browser is served. A highlighter that only works in a re-implementation is a highlighter that does not work.

It also checks the thing most likely to rot. The editor's vocabulary — aggregate operators, builtin functions, #directives, the box-graph relation names — is written down twice, once in Kotlin and once in datalog.js, because the editor cannot call the server. checkVocabularyAgrees compares them, so adding a builtin on one side and forgetting the other fails here rather than silently colouring a real function as an unknown constant.

object DatalogEditorExtensionWorkout {

fun main(args: Array<String>)

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

fun runTests(): Boolean

    fun runTests(): Boolean {
        val js = DatalogEditorExtension.javascript()
        require("FieldLanguageExtensions.register" in js) { "datalog.js does not register with the extension registry" }
        require("id: extensionId" in js)
        require("#datalog" in js)
        require("updateEmbeddedHighlight" in js)
        require("updateDiagnostics" in js)
        require("buildContextPayload" in js)
        require("provideCompletionItems" in js)
        require("registerDatalogLanguage" in js)
        require("setLanguageConfiguration" in js)
        require("analyzeLexical" in js)
        // No Monarch grammar: one bad rule wedges initMonaco with no console error.
        require("setMonarchTokensProvider" !in js) {
            "datalog.js must not install a Monarch grammar — highlighting is decoration-based"
        }

        checkVocabularyAgrees(js)
        checkHeaderIsAcceptedByTheParser()
        runNodeProbe(js)
        println("datalog editor extension workout passed")
        return true
    }

The editor's word lists must equal the parser's.

There is no way to share them at run time — the editor is JavaScript in a browser and DatalogParser is Kotlin on a server — but there is every way to check at build time, and an unchecked duplicate is just a bug with a delay on it.

    private fun checkVocabularyAgrees(js: String) {
        fun jsList(name: String): Set<String> {
            val block = js.substringAfter("const $name = [", "").substringBefore("];", "")
            require(block.isNotBlank()) { "could not find $name in datalog.js" }
            return Regex("\"([^\"]+)\"").findAll(block).map { it.groupValues[1] }.toSet()
        }

        fun agree(what: String, inEditor: Set<String>, inKotlin: Set<String>) {
            require(inEditor == inKotlin) {
                "datalog.js and Kotlin disagree about $what: only in the editor ${inEditor - inKotlin}, " +
                    "only in Kotlin ${inKotlin - inEditor}"
            }
        }

        agree("aggregate operators", jsList("aggregateOps"), DatalogParser.aggregateOps)
        agree("builtin functions", jsList("functionWords"), DatalogParser.functions)
        agree(
            "directives",
            jsList("directiveWords"),
            DatalogExecution.directiveHelp.map { it.first.substringBefore(' ') }.toSet(),
        )

        // The box-graph relation names, which the editor offers as completions once a program has
        // said `#boxes`. Parsed out of the `boxRelations` table's first column.
        val relationBlock = js.substringAfter("const boxRelations = [", "").substringBefore("\n  ];", "")
        val inEditor = Regex("\\[\"([^\"]+)\",").findAll(relationBlock).map { it.groupValues[1] }.toSet()
        agree("box-graph relations", inEditor, dlBoxRelationSchema.map { it.first.substringBefore('(') }.toSet())
    }

The other half of the contract: the header the editor keys on has to be something the parser will swallow, or the same text cannot be both highlighted and run.

source — 268 lines, from line 109
    private fun checkHeaderIsAcceptedByTheParser() {
        val unit = DatalogParser.parse(
            """
            #datalog
            edge(a, b).
            path(X, Y) :- edge(X, Y).
            """.trimIndent()
        )
        require(unit.directives.isEmpty()) { "#datalog should be dropped, got ${unit.directives}" }
        require(unit.rules.size == 2) { "the program after the header did not parse: ${unit.rules}" }
    }

    private fun runNodeProbe(extensionSource: String) {
        val dir = Files.createTempDirectory("datalog-editor-extension-")
        val extensionFile = dir.resolve("datalog-extension.js")
        val probeFile = dir.resolve("probe.js")
        extensionFile.writeText(extensionSource)
        probeFile.writeText(nodeProbeSource())

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

        val process = ProcessBuilder(node, probeFile.toString(), extensionFile.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("datalog node probe passed" in output) { output }
    }

    private fun nodeProbeSource(): String = """
        const fs = require("fs");
        const source = fs.readFileSync(process.argv[2], "utf8");
        const registered = [];
        class Range {
          constructor(startLineNumber, startColumn, endLineNumber, endColumn) {
            this.startLineNumber = startLineNumber;
            this.startColumn = startColumn;
            this.endLineNumber = endLineNumber;
            this.endColumn = endColumn;
          }
        }
        const languageRegistrations = [];
        const languageConfigurations = [];
        global.window = {
          FieldLanguageExtensions: {
            register(extension) {
              registered.push(extension);
              return extension;
            }
          },
          globalMonaco: {
            Range,
            languages: {
              CompletionItemKind: { Function: 1, Keyword: 2, EnumMember: 3, Property: 4 },
              getLanguages: () => languageRegistrations,
              register: (definition) => languageRegistrations.push(definition),
              setLanguageConfiguration: (id, configuration) => languageConfigurations.push({ id, configuration }),
            },
            MarkerSeverity: { Warning: 4, Error: 8 },
            editor: {
              markerCalls: [],
              setModelMarkers(model, owner, markers) {
                this.markerCalls.push({ model, owner, markers });
              }
            }
          }
        };
        global.setTimeout = (fn) => { fn(); return 0; };
        eval(source);

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

        const proof = window.__datalogExtensionProof;
        const tokenize = proof.tokenize;
        const analyzeLexical = proof.analyzeLexical;
        const analyzeProgram = proof.analyzeProgram;

        const text = `
        import afield.data.query.datalog.*
        val d = Datalog()
        d.add(${"\"\"\""}
        #datalog
        % who reaches whom
        edge(a, b). edge(b, c).
        path(X, Y) :- edge(X, Y).
        path(X, Z) :- path(X, Y), edge(Y, Z).
        lonely(X) :- box(X), !path(X, _).
        busy(P, N) :- box(P), N = count : { child(P, _) }, N > 2.
        ${"\"\"\""})
        val plain = ${"\"\"\""}
        not datalog at all
        ${"\"\"\""}
        `.trim();

        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: () => 3,
          getPositionAt: positionAt,
          getOffsetAt: offsetAt,
        };

        // Exactly one region: the #datalog string, not the plain one next to it.
        const regions = extension.findEmbeddedRegions(model);
        if (regions.length !== 1) throw new Error("expected one #datalog region, got " + regions.length);
        if (!regions[0].source.includes("path(X, Z)")) throw new Error("region source missing the recursive rule");
        if (regions[0].source.includes("not datalog at all")) throw new Error("region ran past its closing quotes");

        const collections = [];
        const caret = text.indexOf("path(X, Y) :- edge") + 2;
        const editor = {
          getModel: () => model,
          getPosition: () => positionAt(caret),
          hasTextFocus: () => true,
          createDecorationsCollection(initial) {
            const state = { latest: initial || [], set(value) { this.latest = value; } };
            collections.push(state);
            return state;
          },
          onDidChangeCursorPosition() {},
          onDidChangeModelContent() {},
          onDidChangeModel() {},
        };

        extension.install(editor, window.globalMonaco);
        extension.updateEmbeddedHighlight(editor);
        extension.updateDiagnostics(editor);

        const highlight = editor._datalogHighlightDecorations && editor._datalogHighlightDecorations.latest;
        if (!highlight || highlight.length < 40) {
          throw new Error("expected highlight decorations, got " + (highlight && highlight.length));
        }
        const classes = new Set(highlight.map((d) => d.options.inlineClassName));
        // The distinctions that make a rule readable at a glance.
        if (!classes.has("afield-stacksl-token-definition")) throw new Error("rule heads are not distinguished");
        if (!classes.has("afield-stacksl-token-modifier")) throw new Error("logic variables are not distinguished");
        if (!classes.has("afield-stacksl-token-control")) throw new Error("':-' is not distinguished");
        if (!classes.has("afield-stacksl-token-comment")) throw new Error("a %% comment is not distinguished");
        if (!classes.has("afield-stacksl-token-creative")) throw new Error("the aggregate operator is not distinguished");
        if (!classes.has("afield-stacksl-token-directive")) throw new Error("the #datalog header is not marked");

        // A head and a body use of the same predicate get different colours.
        const headOffset = regions[0].bodyStart + regions[0].source.indexOf("path(X, Y)");
        const bodyOffset = regions[0].bodyStart + regions[0].source.indexOf("path(X, Y), edge");
        const classAt = (offset) => {
          const hit = highlight.find((d) => {
            const p = positionAt(offset);
            return d.range.startLineNumber === p.lineNumber && d.range.startColumn === p.column;
          });
          return hit && hit.options.inlineClassName;
        };
        if (classAt(headOffset) !== "afield-stacksl-token-definition") {
          throw new Error("a rule head should read as a definition, got " + classAt(headOffset));
        }
        if (classAt(bodyOffset) === "afield-stacksl-token-definition") {
          throw new Error("a body goal should not read as a definition");
        }

        // Valid source is clean. This matters more than it looks: a highlighter that cries wolf on
        // correct code is worse than one that says nothing.
        const clean = analyzeLexical(regions[0].source);
        if (clean.length !== 0) throw new Error("valid datalog flagged: " + JSON.stringify(clean));

        const cases = [
          ['edge(a, b.', "is not closed"],
          ['edge("a, b).', "not terminated"],
          ['n = count : { child(P, _) .', "is not closed"],
          ['edge(a, b)', "not terminated with"],
          ['/* open', "not closed with '*/'"],
          ['edge(a, b)).', "no matching"],
        ];
        for (const [src, needle] of cases) {
          const found = analyzeLexical(src);
          if (!found.some((issue) => issue.message.includes(needle))) {
            throw new Error("expected '" + needle + "' for " + JSON.stringify(src) + ", got " + JSON.stringify(found));
          }
        }

        // A session command is not a clause. This underlined every `#why` in a Datalog box until
        // analyzeLexical learned to skip a '#' line the way it skips a '%' one.
        const withDirective = 'cell(a3, 10).\nover(C) :- cell(C, V), V > 5.\n\n#why over(a3)\n';
        if (analyzeLexical(withDirective).length !== 0) {
          throw new Error("a #directive line was flagged: " + JSON.stringify(analyzeLexical(withDirective)));
        }

        // Numbers keep their decimal point: `1.5` is not an unterminated clause.
        if (analyzeLexical("weight(a, 1.5).").length !== 0) {
          throw new Error("a decimal point was mistaken for a clause terminator");
        }

        // Diagnostics reach Monaco as markers, not just as a return value.
        const markerCalls = window.globalMonaco.editor.markerCalls;
        if (!markerCalls.some((call) => call.owner === "datalog-diagnostics")) {
          throw new Error("diagnostics never reached setModelMarkers");
        }

        // Arity is counted lexically, and that is what the badge reports.
        const analysis = analyzeProgram(regions[0].source, 0);
        const path = analysis.predicates.get("path");
        if (!path || !path.arities.has(2) || path.heads !== 2) {
          throw new Error("bad arity analysis " + JSON.stringify({ arities: path && [...path.arities], heads: path && path.heads }));
        }
        const payload = extension.buildContextPayload(editor, () => ({ uid: "box", property: "code" }), {});
        if (!payload || !payload.html.includes("Datalog")) throw new Error("bad context payload " + JSON.stringify(payload));
        if (!payload.html.includes("path/2")) throw new Error("context payload should report the arity: " + payload.html);

        // Completion knows the program's own predicates, and offers directives only after '#'.
        const inBody = positionAt(regions[0].bodyStart + regions[0].source.indexOf("edge(Y, Z)") + 2);
        const completions = extension.provideCompletionItems(editor, model, inBody, { monaco: window.globalMonaco });
        const labels = completions.suggestions.map((s) => s.label);
        if (!labels.includes("edge")) throw new Error("no predicate completions: " + JSON.stringify(labels));
        if (labels.some((l) => String(l).startsWith("#"))) throw new Error("directives offered mid-clause");

        // The standalone language is registered for loose .datalog files, with % comments.
        if (!languageRegistrations.some((l) => l.id === "datalog" && l.extensions.includes(".datalog"))) {
          throw new Error("the datalog language was not registered: " + JSON.stringify(languageRegistrations));
        }
        const configuration = languageConfigurations.find((c) => c.id === "datalog");
        if (!configuration || configuration.configuration.comments.lineComment !== "%") {
          throw new Error("datalog language configuration missing or wrong");
        }

        // A whole datalog document is one scope, whatever case the language id arrives in — a box's
        // code_language is the display label "Datalog", a loose file's is the id "datalog".
        for (const languageId of ["datalog", "Datalog"]) {
          const loose = "path(X, Y) :- edge(X, Y).";
          const looseModel = {
            getLanguageId: () => languageId,
            getValue: () => loose,
            getVersionId: () => 1,
            getPositionAt: (offset) => ({ lineNumber: 1, column: offset + 1 }),
            getOffsetAt: (position) => position.column - 1,
          };
          const looseEditor = {
            getModel: () => looseModel,
            getPosition: () => ({ lineNumber: 1, column: 2 }),
            hasTextFocus: () => true,
            createDecorationsCollection(initial) {
              return { latest: initial || [], set(value) { this.latest = value; } };
            },
            onDidChangeCursorPosition() {},
            onDidChangeModelContent() {},
            onDidChangeModel() {},
          };
          extension.updateEmbeddedHighlight(looseEditor);
          const looseDecorations = looseEditor._datalogHighlightDecorations.latest;
          if (!looseDecorations || looseDecorations.length < 8) {
            throw new Error("a loose ." + languageId + " document was not highlighted: " + JSON.stringify(looseDecorations));
          }
        }

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