RectSLEditorExtensionWorkout.kt
src/jvmMain/kotlin/afield/examples/rectsl/RectSLEditorExtensionWorkout.kt · 201 lines
package afield.examples.rectsl
import afield.lang.rectsl.*
import java.io.File
import java.nio.file.Files
import kotlin.io.path.writeText
import afield.media.image.util.NodeTool
object RectSLEditorExtensionWorkout
FIELD_TEST id: rectsl.editor-extension.workout runner: jvm-main entrypoint: afield.examples.rectsl.RectSLEditorExtensionWorkout tags: [smoke, examples, rectsl, monaco, language-extension] timeoutSec: 30 expect: exitCode: 0 stdoutContains: - "rectsl editor extension workout passed"
fun main(args: Array<String>)
@JvmStatic
fun main(args: Array<String>) {
check(runTests()) { "rectsl editor extension workout failed" }
}
fun runTests(): Boolean
body — 173 lines, from line 28
fun runTests(): Boolean {
val js = RectSLEditorExtension.javascript()
require("FieldLanguageExtensions.register" in js)
require("id: extensionId" in js)
require("#rectsl" in js)
require("updateEmbeddedHighlight" in js)
require("updateFlowLens" in js)
require("buildContextPayload" in js)
require("provideCompletionItems" in js)
require("registerRectSLLanguage" in js)
require("setLanguageConfiguration" in js)
require("analyzeLexical" in js)
runNodeProbe(js)
println("rectsl editor extension workout passed")
return true
}
private fun runNodeProbe(extensionSource: String) {
val dir = Files.createTempDirectory("rectsl-editor-extension-")
val extensionFile = dir.resolve("rectsl-extension.js")
val probeFile = dir.resolve("probe.js")
extensionFile.writeText(extensionSource)
probeFile.writeText(nodeProbeSource())
// Was a private nodeExecutable() that fell back to the bare name "node" when nothing was
// found, which turned "no usable node" into an opaque ProcessBuilder IOException. Its good
// ideas — the AFIELD_NODE/NODE overrides and the repo-local Gradle Node, which let this
// otherwise hermetic workout survive a deliberately small PATH — are now in NodeTool, plus
// a version floor it never had.
val node = NodeTool.resolveNode()
?: error("rectsl 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("rectsl 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;
}
}
global.window = {
FieldLanguageExtensions: {
register(extension) {
registered.push(extension);
return extension;
}
},
globalMonaco: {
Range,
languages: { CompletionItemKind: { Function: 1 } },
MarkerSeverity: { Warning: 4 },
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 !== "rectsl") throw new Error("wrong extension id " + extension.id);
const text = `
import afield.lang.rectsl.*
val program = ${"\"\"\""}
#rectsl
->wall 4 4 meters
"r" rect
-1 0 ->xy
1 ->square
'( 0 -1 1 1 ) ->x
;
${"\"\"\""}
`.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: () => 7,
getPositionAt: positionAt,
getOffsetAt: offsetAt,
};
const regions = extension.findEmbeddedRegions(model);
if (regions.length !== 1) throw new Error("expected one #rectsl region, got " + regions.length);
if (!regions[0].source.includes("\"r\" rect")) throw new Error("region source missing rect");
const collections = [];
const xyOffset = text.indexOf("->xy") + 3;
const editor = {
_actions: [],
getModel: () => model,
getPosition: () => positionAt(xyOffset),
hasTextFocus: () => true,
createDecorationsCollection(initial) {
const state = { latest: initial || [], set(value) { this.latest = value; } };
collections.push(state);
return state;
},
addAction(action) { this._actions.push(action); },
onDidChangeCursorPosition() {},
onDidChangeModelContent() {},
onDidChangeModel() {},
};
extension.install(editor, window.globalMonaco, window.FieldLanguageExtensions);
extension.updateEmbeddedHighlight(editor, { editor, model, monaco: window.globalMonaco });
extension.updateFlowLens(editor, { editor, model, monaco: window.globalMonaco });
extension.updateDiagnostics(editor, { editor, model, monaco: window.globalMonaco });
if (!editor._actions.some((action) => action.id === "toggle-rectsl-flow-lens")) {
throw new Error("missing RectSL flow lens action");
}
const highlight = editor._rectslHighlightDecorations && editor._rectslHighlightDecorations.latest;
if (!highlight || highlight.length < 8) throw new Error("expected highlight decorations, got " + (highlight && highlight.length));
const flow = editor._rectslFlowDecorations && editor._rectslFlowDecorations.latest;
if (!flow || flow.length < 2) throw new Error("expected flow decorations for ->xy, got " + (flow && flow.length));
const payload = extension.buildContextPayload(editor, () => ({ uid: "box", property: "code" }), {});
if (!payload || !payload.html.includes("RectSL") || !payload.html.includes("2->0")) {
throw new Error("bad context payload " + JSON.stringify(payload));
}
// Richer badge: the active word's documented signature is surfaced (->xy -> "x y --").
if (!payload.html.includes("x y --")) {
throw new Error("context payload missing word signature " + JSON.stringify(payload));
}
// Lexical diagnostics: valid source is clean, an unterminated quote is flagged.
const analyzeLexical = window.__rectslExtensionProof.analyzeLexical;
if (analyzeLexical(regions[0].source).length !== 0) {
throw new Error("valid rectsl flagged: " + JSON.stringify(analyzeLexical(regions[0].source)));
}
if (analyzeLexical("'( 1 2 ->x").length === 0) {
throw new Error("unterminated quote not flagged");
}
const completionPosition = positionAt(text.indexOf("->sq") + 4);
const completions = extension.provideCompletionItems(editor, model, completionPosition, { monaco: window.globalMonaco });
if (!completions || !completions.suggestions || !completions.suggestions.some((item) =>
item.label === "->square" &&
item.data && item.data.rectslEffect === "mutator" &&
String(item.documentation.value).includes("Set width and height")
)) {
throw new Error("missing documented ->square completion " + JSON.stringify(completions));
}
console.log("rectsl node probe passed");
""".trimIndent()
}