Skip to content

Commit d5158fd

Browse files
Fix editor argument suggestions and reject conflicting Blocks starts
1 parent a23778a commit d5158fd

10 files changed

Lines changed: 469 additions & 226 deletions

File tree

‎Tooling/shared/ser-language-core.js‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,8 @@
231231

232232
lines.forEach((line, index) => {
233233
const lineNumber = index + 1;
234-
const codeOnly = withoutQuotedText(line);
234+
// A word starting with # begins a comment that runs to the end of the line.
235+
const codeOnly = withoutQuotedText(line).replace(/(^|\s)#.*$/, "$1");
235236
if (/(^|\s)\.\.\.(?=\s|$)/.test(codeOnly)) {
236237
diagnostics.push({
237238
severity: "error",
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import assert from "node:assert/strict";
2+
import fs from "node:fs";
3+
import { createRequire } from "node:module";
4+
import path from "node:path";
5+
import test from "node:test";
6+
import { fileURLToPath } from "node:url";
7+
import vm from "node:vm";
8+
9+
// Runs the real Blocks block definitions, code generator and workspace checks
10+
// in a headless Blockly workspace (no browser page).
11+
const require = createRequire(import.meta.url);
12+
const toolingDirectory = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
13+
const repositoryDirectory = path.resolve(toolingDirectory, "..");
14+
const Blockly = require(path.join(toolingDirectory, "node_modules", "blockly"));
15+
const core = require(path.join(toolingDirectory, "shared", "ser-language-core.js"));
16+
const { SER_TRUTH_TABLE } = require(
17+
path.join(repositoryDirectory, "VS Code Extension", "out", "ser_method_info.js")
18+
);
19+
const editorSource = fs.readFileSync(
20+
path.join(toolingDirectory, "visual-editor", "src", "editor.js"),
21+
"utf8"
22+
);
23+
24+
function sliceBetween(source, startMarker, endMarker) {
25+
const start = startMarker ? source.indexOf(startMarker) : 0;
26+
const end = source.indexOf(endMarker);
27+
assert.ok(start >= 0 && end > start, `editor.js no longer contains ${startMarker || "its start"}…${endMarker}`);
28+
return source.slice(start, end);
29+
}
30+
31+
function createHeadlessEditor() {
32+
const definitions = sliceBetween(editorSource, null, " function shadow(");
33+
const checks = sliceBetween(editorSource, " function entryAncestor(", " function renderDiagnostics(");
34+
const context = { Blockly, SER_TRUTH_TABLE, SERLanguageCore: core, console };
35+
vm.createContext(context);
36+
vm.runInContext(
37+
`${definitions}
38+
const workspace = new Blockly.Workspace();
39+
globalThis.workspace = workspace;
40+
globalThis.generator = serGenerator;
41+
${checks}
42+
globalThis.check = workspaceDiagnostics;
43+
})();`,
44+
context
45+
);
46+
return context;
47+
}
48+
49+
function addRoot(editor, type, y, message, fields = {}) {
50+
const root = editor.workspace.newBlock(type);
51+
for (const [name, value] of Object.entries(fields)) root.setFieldValue(value, name);
52+
root.moveBy(0, y);
53+
const action = editor.workspace.newBlock("ser_broadcast");
54+
const text = editor.workspace.newBlock("ser_text");
55+
text.setFieldValue(message, "TEXT");
56+
action.getInput("MESSAGE").connection.connect(text.outputConnection);
57+
const target = editor.workspace.newBlock("ser_target");
58+
action.getInput("TARGET").connection.connect(target.outputConnection);
59+
const duration = editor.workspace.newBlock("ser_duration");
60+
action.getInput("DURATION").connection.connect(duration.outputConnection);
61+
root.getInput("DO").connection.connect(action.previousConnection);
62+
return root;
63+
}
64+
65+
function diagnosticsFor(editor) {
66+
const code = editor.generator.workspaceToCode(editor.workspace).trimEnd();
67+
// Copy out of the sandbox so assertions compare plain arrays from this realm.
68+
return JSON.parse(JSON.stringify(editor.check(code)));
69+
}
70+
71+
function errorCodes(diagnostics) {
72+
return diagnostics.filter(item => item.severity === "error").map(item => item.code);
73+
}
74+
75+
test("blocks a manual start mixed with an event start, in either order", () => {
76+
// C01: placed first, SER rejects the file; placed last, the manual actions run on the event.
77+
for (const manualFirst of [true, false]) {
78+
const editor = createHeadlessEditor();
79+
addRoot(editor, "ser_when_manual", manualFirst ? 0 : 400, "manual");
80+
addRoot(editor, "ser_when_event", 200, "event", { EVENT: "RoundStarted" });
81+
82+
const diagnostics = diagnosticsFor(editor);
83+
assert.deepEqual(errorCodes(diagnostics), ["mixed-manual-entry"]);
84+
assert.match(
85+
diagnostics.find(item => item.code === "mixed-manual-entry").message,
86+
/separate script/
87+
);
88+
}
89+
});
90+
91+
test("blocks a manual start mixed with a command start", () => {
92+
const editor = createHeadlessEditor();
93+
addRoot(editor, "ser_when_command", 0, "command", { COMMAND: "heal" });
94+
addRoot(editor, "ser_when_manual", 200, "manual");
95+
96+
assert.deepEqual(errorCodes(diagnosticsFor(editor)), ["mixed-manual-entry"]);
97+
});
98+
99+
test("blocks more than one manual start", () => {
100+
const editor = createHeadlessEditor();
101+
addRoot(editor, "ser_when_manual", 0, "first");
102+
addRoot(editor, "ser_when_manual", 200, "second");
103+
104+
const diagnostics = diagnosticsFor(editor);
105+
assert.deepEqual(errorCodes(diagnostics), ["multiple-manual-entries"]);
106+
assert.match(
107+
diagnostics.find(item => item.code === "multiple-manual-entries").message,
108+
/separate script/
109+
);
110+
});
111+
112+
test("still allows one manual start, and several event or command starts", () => {
113+
const manual = createHeadlessEditor();
114+
addRoot(manual, "ser_when_manual", 0, "manual");
115+
assert.deepEqual(errorCodes(diagnosticsFor(manual)), []);
116+
117+
// SER splits a file into one part per event or command start.
118+
const flagged = createHeadlessEditor();
119+
addRoot(flagged, "ser_when_event", 0, "event", { EVENT: "RoundStarted" });
120+
addRoot(flagged, "ser_when_command", 200, "command", { COMMAND: "heal" });
121+
assert.deepEqual(errorCodes(diagnosticsFor(flagged)), []);
122+
});

‎Tooling/tests/editor-logic.test.mjs‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,75 @@ test("tracks the active method argument after tilde-escaped quotes", () => {
6060
assert.equal(context.methodName, "Notify");
6161
assert.equal(context.activeArgument, 3);
6262
});
63+
64+
const shippedManifest = require(
65+
path.join(repositoryDirectory, "VS Code Extension", "out", "ser-language.json")
66+
);
67+
68+
test("counts only arguments before the cursor when it sits in whitespace", () => {
69+
// A18: editing `SetRole @all ClassD` with the cursor before ClassD asks for the role.
70+
const line = "SetRole @all ClassD";
71+
const cursor = line.indexOf("ClassD") - 1;
72+
const context = editorLogic.getMethodCallContext(line, cursor, shippedManifest.methods);
73+
74+
assert.equal(context.methodName, "SetRole");
75+
assert.equal(context.activeArgument, 1);
76+
assert.equal(shippedManifest.methods.SetRole.arguments[context.activeArgument].name, "newRole");
77+
78+
const trailing = "SetRole @all ";
79+
assert.equal(
80+
editorLogic.getMethodCallContext(trailing, trailing.length, shippedManifest.methods).activeArgument,
81+
1
82+
);
83+
});
84+
85+
test("resolves the method inside text interpolation", () => {
86+
// A19: `{ToDuration 3 }` inside quoted text is its own method call.
87+
const line = 'Print "Delay: {ToDuration 3 }"';
88+
const context = editorLogic.getMethodCallContext(
89+
line,
90+
line.indexOf("}"),
91+
shippedManifest.methods
92+
);
93+
94+
assert.equal(context.methodName, "ToDuration");
95+
assert.equal(context.activeArgument, 1);
96+
assert.equal(shippedManifest.methods.ToDuration.arguments[1].name, "unit");
97+
});
98+
99+
test("resolves interpolation while the closing quote is not typed yet", () => {
100+
const line = 'Print "Delay: {ToDuration 3 ';
101+
const context = editorLogic.getMethodCallContext(line, line.length, shippedManifest.methods);
102+
103+
assert.equal(context.methodName, "ToDuration");
104+
assert.equal(context.activeArgument, 1);
105+
});
106+
107+
test("keeps tilde-escaped interpolation braces as plain text for method context", () => {
108+
const line = 'Print "literal ~{ToDuration 3 }"';
109+
const context = editorLogic.getMethodCallContext(
110+
line,
111+
line.indexOf("}"),
112+
shippedManifest.methods
113+
);
114+
115+
assert.equal(context.methodName, "Print");
116+
assert.equal(context.activeArgument, 0);
117+
});
118+
119+
test("handles quoted text nested inside an interpolation", () => {
120+
const methods = {
121+
Print: { arguments: [{ syntax: "\"text\"" }] },
122+
Join: { arguments: [{ syntax: "\"first\"" }, { syntax: "\"second\"" }] }
123+
};
124+
const line = 'Print "a {Join "x}" } b"';
125+
const cursor = line.lastIndexOf("}");
126+
const context = editorLogic.getMethodCallContext(line, cursor, methods);
127+
128+
assert.equal(context.methodName, "Join");
129+
assert.equal(context.activeArgument, 1);
130+
assert.deepEqual(
131+
editorLogic.tokenizeSerExpression(line).map(token => token.text),
132+
["Print", '"a {Join "x}" } b"']
133+
);
134+
});

‎Tooling/tests/language-core.test.mjs‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,3 +111,15 @@ test("warns when a forever loop has no wait", () => {
111111
const diagnostics = core.validateGeneratedCode("forever\nPrint \"busy\"\nend\n");
112112
assert.ok(diagnostics.some(item => item.code === "forever-without-wait"));
113113
});
114+
115+
test("does not treat ellipses in comments as missing inputs", () => {
116+
// A20: comment lines are valid SER and must not get a block-input error.
117+
assert.deepEqual(core.validateGeneratedCode('# ...\nPrint "hello"'), []);
118+
assert.deepEqual(core.validateGeneratedCode(' # todo ...\nPrint "hello"'), []);
119+
assert.deepEqual(core.validateGeneratedCode('Print "hello" # ...'), []);
120+
assert.equal(
121+
core.validateGeneratedCode('# note\nBroadcast @all ... "hi"')
122+
.filter(item => item.code === "missing-value").map(item => item.line).join(),
123+
"2"
124+
);
125+
});

‎Tooling/visual-editor/src/editor.js‎

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -952,6 +952,26 @@
952952
});
953953
}
954954

955+
// SER can split one file into several event or command parts, but a
956+
// manual part has no start line of its own. Placed above them, SER rejects
957+
// the file; placed below, its actions join the part above it.
958+
const manualEntries = entries.filter(entry => entry.type === "ser_when_manual");
959+
if (manualEntries.length > 1) {
960+
diagnostics.push({
961+
severity: "error",
962+
code: "multiple-manual-entries",
963+
message: "A script can have only one “when I run this script manually” block. " +
964+
"Put all its actions in one block, or save each one as a separate script."
965+
});
966+
} else if (manualEntries.length === 1 && entries.length > 1) {
967+
diagnostics.push({
968+
severity: "error",
969+
code: "mixed-manual-entry",
970+
message: "“When I run this script manually” can't share a script with an event or command. " +
971+
"Save the manual part as a separate script."
972+
});
973+
}
974+
955975
for (const entry of entries) {
956976
if (!entry.getInputTargetBlock("DO")) {
957977
diagnostics.push({
@@ -1530,12 +1550,14 @@
15301550

15311551
elements.download.addEventListener("click", () => {
15321552
if (elements.download.disabled) return;
1533-
downloadText(
1534-
safeFilename(elements.scriptName.value, "ser"),
1535-
elements.codeOutput.value,
1536-
"text/plain;charset=utf-8"
1537-
);
1538-
showToast("Downloaded. Put it in the SER script directory, then run serrun or serreload.");
1553+
const filename = safeFilename(elements.scriptName.value, "ser");
1554+
downloadText(filename, elements.codeOutput.value, "text/plain;charset=utf-8");
1555+
// Event and command scripts only start working after serreload picks them up.
1556+
const isManualScript = workspace.getAllBlocks(false)
1557+
.every(block => !ENTRY_TYPES.has(block.type) || block.type === "ser_when_manual");
1558+
showToast(isManualScript
1559+
? `Downloaded. Put it in the SER script directory, then run serrun ${filename.replace(/\.ser$/, "")}.`
1560+
: "Downloaded. Put it in the SER script directory, then run serreload.");
15391561
});
15401562

15411563
elements.scriptName.addEventListener("input", scheduleAutosave);

‎Tooling/visual-editor/src/index.html‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,8 @@ <h2>Try it on your server</h2>
8888
<ol>
8989
<li>Put the file in the SER script directory shown by <code>serhelp start</code>.</li>
9090
<li>If your host blocks <code>.ser</code>, rename it to <code>.txt</code>.</li>
91-
<li>For a new manual script, run <code>serrun name</code>. For edits, run <code>serreload</code>.</li>
91+
<li>For a new event or command script, run <code>serreload</code>. Then make the event happen or type the command.</li>
92+
<li>For a new manual script, run <code>serrun name</code>. After editing any script, run <code>serreload</code>.</li>
9293
<li>Use <code>serstatus</code> if it does not run.</li>
9394
</ol>
9495
<p>Blocks teaches common tasks. The text editor and VS Code extension expose the full language.</p>

0 commit comments

Comments
 (0)