BriefEditorExtensionWorkout.kt
src/jvmMain/kotlin/afield/examples/brief/BriefEditorExtensionWorkout.kt · 221 lines
package afield.examples.brief
import afield.lang.brief.BriefEditorExtension
import afield.media.image.util.NodeTool
import java.nio.file.Files
import kotlin.io.path.writeText
FIELD_TEST id: brief.editor-extension.workout runner: jvm-main entrypoint: afield.examples.brief.BriefEditorExtensionWorkout tags: [smoke, examples, brief, monaco, language-extension] timeoutSec: 30 expect: exitCode: 0 stdoutContains: - "brief editor extension workout passed"
object BriefEditorExtensionWorkout
Runs brief.js for real, in node, against a mocked Monaco.
The point of doing it this way rather than asserting on the file's text: brief.js is served to a
browser and nothing else executes it, so without this the first time the code ran would be in front
of a person, and a throw inside an AMD factory does not reach the console — it wedges initMonaco
and every symptom reads "editor not synced yet" (bootstrapMonaco.js:52-57). The probe loads the
exact bytes off the classpath under bare eval, which is the path the file's non-AMD branch exists
to support.
fun main(args: Array<String>)
@JvmStatic
fun main(args: Array<String>) {
check(runTests()) { "brief editor extension workout failed" }
}
fun runTests(): Boolean
body — 185 lines, from line 36
fun runTests(): Boolean {
val js = BriefEditorExtension.javascript()
require("FieldLanguageExtensions.register" in js)
require("#brief" in js)
require("updateEmbeddedHighlight" in js)
require("updateFlowLens" in js)
require("updateDiagnostics" in js)
require("buildContextPayload" in js)
require("provideCompletionItems" in js)
require("registerBriefLanguage" in js)
require("setLanguageConfiguration" in js)
require("analyzeLexical" in js)
// No Monarch grammar, on purpose. A bad rule in one would wedge the editor with no error.
require("setMonarchTokensProvider" !in js) { "brief.js must not register a Monarch grammar" }
runNodeProbe(js)
println("brief editor extension workout passed")
return true
}
private fun runNodeProbe(extensionSource: String) {
val dir = Files.createTempDirectory("brief-editor-extension-")
val extensionFile = dir.resolve("brief-extension.js")
val probeFile = dir.resolve("probe.js")
extensionFile.writeText(extensionSource)
probeFile.writeText(nodeProbeSource())
val node = NodeTool.resolveNode()
?: error("brief 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("brief 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: { Keyword: 1 } },
MarkerSeverity: { Error: 8, 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 !== "brief") throw new Error("wrong extension id " + extension.id);
const text = `
import afield.lang.brief.*
val plan = ${"\"\"\""}
#brief
point west at (0, 0)
point east at (9, 0)
1e10 fix west east
prefer spine at 1/3 along (west, east)
insist distance(west, spine) = 6
${"\"\"\""}
`.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 caret = text.indexOf("insist") + 3;
const model = {
getLanguageId: () => "kotlin",
getValue: () => text,
getVersionId: () => 3,
getPositionAt: positionAt,
getOffsetAt: offsetAt,
};
const collections = [];
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;
},
addAction() {},
onDidChangeCursorPosition() {},
onDidChangeModelContent() {},
onDidChangeModel() {},
};
const regions = extension.findEmbeddedRegions(model);
if (regions.length !== 1) throw new Error("expected one #brief region, got " + regions.length);
if (!regions[0].source.includes("prefer spine at")) throw new Error("region source is wrong");
extension.updateEmbeddedHighlight(editor);
extension.updateFlowLens(editor);
extension.updateDiagnostics(editor);
const highlight = editor._briefHighlightDecorations && editor._briefHighlightDecorations.latest;
if (!highlight || highlight.length < 20) {
throw new Error("expected highlight decorations, got " + (highlight && highlight.length));
}
// The strength lens: only the statements that did NOT write a strength get an annotation, so
// this brief has exactly one (`prefer` and `insist` and `1e10` all wrote theirs).
const flow = editor._briefFlowDecorations && editor._briefFlowDecorations.latest;
if (!flow || flow.length !== 0) {
throw new Error("every statement wrote its strength, so expected no lens, got " + JSON.stringify(flow));
}
const markers = window.globalMonaco.editor.markerCalls;
if (markers.length !== 1 || markers[0].owner !== "brief") {
throw new Error("expected one marker call owned by brief, got " + JSON.stringify(markers));
}
if (markers[0].markers.length !== 0) {
throw new Error("a valid brief was flagged: " + JSON.stringify(markers[0].markers));
}
const analyzeLexical = window.__briefExtensionProof.analyzeLexical;
if (analyzeLexical(regions[0].source).length !== 0) {
throw new Error("valid brief flagged: " + JSON.stringify(analyzeLexical(regions[0].source)));
}
if (analyzeLexical("prefer a at 1/3 along (b, c").length === 0) {
throw new Error("unclosed bracket not flagged");
}
if (analyzeLexical("insist a ~ b").length === 0) {
throw new Error("a meaningless character was not flagged");
}
if (analyzeLexical("offset 3").length === 0) {
throw new Error("a glue word beginning a statement was not flagged");
}
const payload = extension.buildContextPayload(editor);
if (!payload || payload.label !== "Brief") throw new Error("bad context payload " + JSON.stringify(payload));
if (payload.detail !== "3 constraints") {
throw new Error("expected 3 constraints in the badge, got " + JSON.stringify(payload));
}
// The caret is on `insist`, so the badge documents that strength rather than the language.
if (!payload.documentation.includes("1e3")) {
throw new Error("badge should document the word under the caret: " + JSON.stringify(payload));
}
const completions = extension.provideCompletionItems(editor, model, positionAt(caret));
const labels = completions.map((item) => item.label);
["whisper", "prefer", "insist", "demand", "point", "panel", "planar", "mirrors", "distance", "area"]
.forEach((word) => {
if (!labels.includes(word)) throw new Error("completion missing " + word);
});
if (completions.some((item) => item.language !== "brief")) {
throw new Error("every completion item must be tagged with the language");
}
// Outside a region, nothing: a Route A extension has to return nothing when the caret is not
// its business, because the registry concatenates every extension's items with no arbitration.
const outside = extension.provideCompletionItems(editor, model, positionAt(2));
if (outside.length !== 0) throw new Error("expected no completions outside the region");
console.log("brief node probe passed");
""".trimIndent()
}