Makros: GET/POST-Anfragen mit JSON-Ergebnis und FOR-Iteration

Erweitert die Makro-Sprache um Netzwerkzugriff: "x = GET(url)"/
"x = POST(url; body)" binden die geparste JSON-Antwort an eine Variable;
FOR item IN x DO ... END iteriert, wenn x ein JSON-Array ist, über dessen
Elemente; JSONGET(wert; pfad) liest ein Feld heraus (punktgetrennt,
Zahl = Array-Index) und funktioniert – anders als GET/POST – überall in
einem Ausdruck verschachtelt, nicht nur als ganze Zuweisung.

Da ein echter Netzwerkaufruf nicht synchron laufen kann, ohne den Tab
einzufrieren, bekommt der Makro-Interpreter einen separaten asynchronen
Zweig (execBlockAsync/execStatementAsync); der bestehende synchrone
Interpreter, den auch gewöhnliche "Mit Schleife"-Funktionen mitten in der
synchronen Neuberechnung nutzen, bleibt unangetastet. Netzwerkaufrufe
werden pro Makro einmalig mit Zielname bestätigt (dauerhaft gemerkt); die
bestehende "ein Lauf = ein Undo-Schritt"-Garantie, die bislang auf
Synchronität beruhte, wird jetzt explizit wiederhergestellt statt sich
weiter auf die Zeit-Koaleszenz zu verlassen.

Zwei echte Lücken erst beim eigenen Live-Test gefunden und behoben: (1)
JSONGET direkt in eine Aktion verschachtelt (statt erst einer Variable
zugewiesen) scheiterte zunächst an #NAME?; (2) JSONGET(...) direkt als
FOR-Quelle (ohne Zwischenvariable) scheiterte sowohl an der
Speicher-Zeit-Prüfung als auch zur Laufzeit, da substitute() einen
Funktionsaufruf nie auswertet.

Live gegen eine neue same-origin-Fixture (public/test-fixtures/sample.json)
verifiziert: echter GET, echter Bestätigungsdialog, FOR/JSONGET über die
echte Antwort, kein erneuter Dialog beim zweiten Lauf.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
wkh
2026-09-09 11:10:19 +02:00
co-authored by Claude Sonnet 5
parent a59fda25b0
commit 3ae3d81066
15 changed files with 1043 additions and 101 deletions
+267 -38
View File
@@ -2,7 +2,14 @@ import { describe, it, expect } from 'vitest';
import { parseMacroBlock } from '../parser';
import { runMacroActions } from '../evaluator';
import { buildFunctionIndex, expandMacroBlock, type FunctionDef } from '../userFunctions';
import { FormulaError, type CellRef, type CellValue, type EngineHost, type MacroWriteHost } from '../types';
import {
FormulaError,
type CellRef,
type CellValue,
type EngineHost,
type MacroNetworkHost,
type MacroWriteHost,
} from '../types';
type NamedRangeShape = { qualifier: string; sheetQualifier?: string; start: { col: number; row: number }; end: { col: number; row: number } };
@@ -60,19 +67,47 @@ function makeWriteHost(): { host: MacroWriteHost; calls: RecordedCall[] } {
return { host, calls };
}
const runMacro = (src: string, host: EngineHost, writeHost: MacroWriteHost) =>
runMacroActions(parseMacroBlock(src), host, writeHost);
/** A mock `MacroNetworkHost` -- `responses` maps "METHOD url" to either a
* value to resolve with (wrapped as `{ok:true, value}`) or an Error to
* reject with (`{ok:false, error}`); an un-mapped request rejects with a
* loud "no mock for this request" error so a test that forgets to mock a
* call fails clearly instead of hanging. */
function makeNetHost(
responses: Record<string, { ok: true; value: unknown } | { ok: false; error: Error }>,
): { host: MacroNetworkHost; calls: { method: string; url: string; body?: string }[] } {
const calls: { method: string; url: string; body?: string }[] = [];
const host: MacroNetworkHost = {
async request(method, url, body) {
calls.push({ method, url, body });
const key = `${method} ${url}`;
const mocked = responses[key];
if (!mocked) throw new Error(`no mock for ${key}`);
if (!mocked.ok) throw mocked.error;
return mocked.value;
},
};
return { host, calls };
}
const emptyNetHost: MacroNetworkHost = { request: () => Promise.reject(new Error('no network calls expected')) };
const runMacro = (
src: string,
host: EngineHost,
writeHost: MacroWriteHost,
netHost: MacroNetworkHost = emptyNetHost,
) => runMacroActions(parseMacroBlock(src), host, writeHost, netHost);
describe('runMacroActions -- SETVALUE target resolution', () => {
it('a literal cell target writes exactly one cell', () => {
it('a literal cell target writes exactly one cell', async () => {
const { host: writeHost, calls } = makeWriteHost();
runMacro('SETVALUE(Umsatz::B2; 42)', makeHost({}), writeHost);
await runMacro('SETVALUE(Umsatz::B2; 42)', makeHost({}), writeHost);
expect(calls).toEqual([{ op: 'setValue', args: [[{ tableId: 'Umsatz', col: 1, row: 1 }], '42'] }]);
});
it('a range target broadcasts the same value to every cell in one call', () => {
it('a range target broadcasts the same value to every cell in one call', async () => {
const { host: writeHost, calls } = makeWriteHost();
runMacro('SETVALUE(Umsatz::A1:A3; "x")', makeHost({}), writeHost);
await runMacro('SETVALUE(Umsatz::A1:A3; "x")', makeHost({}), writeHost);
expect(calls).toHaveLength(1);
const [cells, text] = calls[0].args as [CellRef[], string];
expect(cells).toEqual([
@@ -83,59 +118,59 @@ describe('runMacroActions -- SETVALUE target resolution', () => {
expect(text).toBe('x');
});
it('a target resolved through a FOR loop variable (substitute() through env)', () => {
it('a target resolved through a FOR loop variable (substitute() through env)', async () => {
const { host: writeHost, calls } = makeWriteHost();
runMacro('FOR c IN Umsatz::A1:A2 DO SETVALUE(c; 0) END', makeHost({}), writeHost);
await runMacro('FOR c IN Umsatz::A1:A2 DO SETVALUE(c; 0) END', makeHost({}), writeHost);
expect(calls).toEqual([
{ op: 'setValue', args: [[{ tableId: 'Umsatz', col: 0, row: 0 }], '0'] },
{ op: 'setValue', args: [[{ tableId: 'Umsatz', col: 0, row: 1 }], '0'] },
]);
});
it('a value that starts with "=" is stringified as-is -- the store applies it as a live formula, no special-casing needed here', () => {
it('a value that starts with "=" is stringified as-is -- the store applies it as a live formula, no special-casing needed here', async () => {
const { host: writeHost, calls } = makeWriteHost();
runMacro('SETVALUE(Umsatz::A1; "=SUM(" & "A2:A3" & ")")', makeHost({}), writeHost);
await runMacro('SETVALUE(Umsatz::A1; "=SUM(" & "A2:A3" & ")")', makeHost({}), writeHost);
expect(calls[0].args[1]).toBe('=SUM(A2:A3)');
});
it('rejects an unqualified target at runtime (the static save-time check in document.ts is the primary guard; this is the interpreter\'s own defensive backstop)', () => {
it('rejects an unqualified target at runtime (the static save-time check in document.ts is the primary guard; this is the interpreter\'s own defensive backstop)', async () => {
const { host: writeHost } = makeWriteHost();
expect(() => runMacro('SETVALUE(A1; 1)', makeHost({}), writeHost)).toThrow(/Tabelle nennen/);
await expect(runMacro('SETVALUE(A1; 1)', makeHost({}), writeHost)).rejects.toThrow(/Tabelle nennen/);
});
it('rejects a computed (non-reference) target', () => {
it('rejects a computed (non-reference) target', async () => {
const { host: writeHost } = makeWriteHost();
expect(() => runMacro('SETVALUE(1+1; 1)', makeHost({}), writeHost)).toThrow(/Zell- oder Bereichsbezug/);
await expect(runMacro('SETVALUE(1+1; 1)', makeHost({}), writeHost)).rejects.toThrow(/Zell- oder Bereichsbezug/);
});
it('rejects an unknown table name', () => {
it('rejects an unknown table name', async () => {
const { host: writeHost } = makeWriteHost();
expect(() => runMacro('SETVALUE(Nirgendwo::A1; 1)', makeHost({}), writeHost)).toThrow(/unbekannte Tabelle/);
await expect(runMacro('SETVALUE(Nirgendwo::A1; 1)', makeHost({}), writeHost)).rejects.toThrow(/unbekannte Tabelle/);
});
});
describe('runMacroActions -- RETURN / fall-through', () => {
it('RETURN short-circuits and its value is returned', () => {
it('RETURN short-circuits and its value is returned', async () => {
const { host: writeHost } = makeWriteHost();
expect(runMacro('SETVALUE(Umsatz::A1; 1) RETURN "fertig"', makeHost({}), writeHost)).toBe('fertig');
await expect(runMacro('SETVALUE(Umsatz::A1; 1) RETURN "fertig"', makeHost({}), writeHost)).resolves.toBe('fertig');
});
it('a RETURN inside an IF stops the remaining statements from running', () => {
it('a RETURN inside an IF stops the remaining statements from running', async () => {
const { host: writeHost, calls } = makeWriteHost();
runMacro('IF 1 = 1 THEN RETURN "abgebrochen" END SETVALUE(Umsatz::A1; 1)', makeHost({}), writeHost);
await runMacro('IF 1 = 1 THEN RETURN "abgebrochen" END SETVALUE(Umsatz::A1; 1)', makeHost({}), writeHost);
expect(calls).toHaveLength(0);
});
it('falling off the end without RETURN yields null, not an error (unlike an ordinary block-kind custom function)', () => {
it('falling off the end without RETURN yields null, not an error (unlike an ordinary block-kind custom function)', async () => {
const { host: writeHost } = makeWriteHost();
expect(runMacro('SETVALUE(Umsatz::A1; 1)', makeHost({}), writeHost)).toBeNull();
await expect(runMacro('SETVALUE(Umsatz::A1; 1)', makeHost({}), writeHost)).resolves.toBeNull();
});
});
describe('runMacroActions -- the full action vocabulary dispatches correctly', () => {
it('every non-SETVALUE action evaluates its (string/number) args and calls the matching writeHost method', () => {
it('every non-SETVALUE action evaluates its (string/number) args and calls the matching writeHost method', async () => {
const { host: writeHost, calls } = makeWriteHost();
runMacro(
await runMacro(
[
'INSERTROW("Umsatz"; 3)',
'DELETEROW("Umsatz"; 4)',
@@ -165,44 +200,52 @@ describe('runMacroActions -- the full action vocabulary dispatches correctly', (
]);
});
it('a string argument built via & concatenation and a local variable both work, since every non-target argument goes through the ordinary expression pipeline', () => {
it('a string argument built via & concatenation and a local variable both work, since every non-target argument goes through the ordinary expression pipeline', async () => {
const { host: writeHost, calls } = makeWriteHost();
runMacro('n = "Kopie" ADDTABLE("Blatt1"; "Neu " & n)', makeHost({}), writeHost);
await runMacro('n = "Kopie" ADDTABLE("Blatt1"; "Neu " & n)', makeHost({}), writeHost);
expect(calls).toEqual([{ op: 'addTable', args: ['Blatt1', 'Neu Kopie'] }]);
});
it('an unknown action name throws', () => {
it('an unknown action name throws', async () => {
const { host: writeHost } = makeWriteHost();
expect(() => runMacro('FOOBAR(1)', makeHost({}), writeHost)).toThrow(/Unbekannte Aktion/);
await expect(runMacro('FOOBAR(1)', makeHost({}), writeHost)).rejects.toThrow(/Unbekannte Aktion/);
});
it('a wrong argument count throws', () => {
it('a wrong argument count throws', async () => {
const { host: writeHost } = makeWriteHost();
expect(() => runMacro('ADDSHEET("x"; "y")', makeHost({}), writeHost)).toThrow(/erwartet 1 Argument/);
await expect(runMacro('ADDSHEET("x"; "y")', makeHost({}), writeHost)).rejects.toThrow(/erwartet 1 Argument/);
});
});
describe('runMacroActions -- macro-specific step budget', () => {
it('a loop that executes many structural actions is stopped by the (smaller) macro budget, not the ordinary 10,000-step FOR budget', () => {
it('a loop that executes many structural actions is stopped by the (smaller) macro budget, not the ordinary 10,000-step FOR budget', async () => {
const { host: writeHost } = makeWriteHost();
// 2,000 iterations, each one action call -- exceeds MAX_MACRO_STEPS
// (1,000) but would be nowhere near the ordinary MAX_LOOP_STEPS
// (10,000) an ordinary block-kind function loop gets.
expect(() =>
await expect(
runMacro('FOR c IN Umsatz::A1:A2000 DO INSERTROW("Umsatz"; 1) END', makeHost({}), writeHost),
).toThrow(/zu viele Schritte|zu viele Zellen/);
).rejects.toThrow(/zu viele Schritte|zu viele Zellen/);
});
it('sequential GET calls in a loop also count toward the budget', async () => {
const { host: writeHost } = makeWriteHost();
const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value: 1 } });
await expect(
runMacro('FOR c IN Umsatz::A1:A2000 DO r = GET("https://x") END', makeHost({}), writeHost, netHost),
).rejects.toThrow(/zu viele Schritte|zu viele Zellen/);
});
});
describe('runMacroActions -- nested custom-function calls inside a macro expression', () => {
it('a SETVALUE value can call a custom function, resolved via expandMacroBlock before running', () => {
it('a SETVALUE value can call a custom function, resolved via expandMacroBlock before running', async () => {
const { host: writeHost, calls } = makeWriteHost();
const defs: FunctionDef[] = [
{ id: 'fn-1', name: 'MARGE', params: ['a', 'b'], body: 'a - b', kind: 'expression' },
];
const index = buildFunctionIndex(defs);
const block = expandMacroBlock(parseMacroBlock('SETVALUE(Umsatz::A1; MARGE(10; 3))'), index);
runMacroActions(block, makeHost({}), writeHost);
await runMacroActions(block, makeHost({}), writeHost, emptyNetHost);
expect(calls).toEqual([{ op: 'setValue', args: [[{ tableId: 'Umsatz', col: 0, row: 0 }], '7'] }]);
});
@@ -218,10 +261,196 @@ describe('runMacroActions -- nested custom-function calls inside a macro express
});
describe('runMacroActions -- error propagation', () => {
it('an error value in a value expression aborts with a clear message, not a silently-written "#ERR" text', () => {
it('an error value in a value expression aborts with a clear message, not a silently-written "#ERR" text', async () => {
const { host: writeHost } = makeWriteHost();
expect(() => runMacro('SETVALUE(Umsatz::A1; 1/0)', makeHost({}), writeHost)).toThrow(
await expect(runMacro('SETVALUE(Umsatz::A1; 1/0)', makeHost({}), writeHost)).rejects.toThrow(
new FormulaError('#DIV/0!').code,
);
});
});
describe('runMacroActions -- GET/POST', () => {
it('GET binds the parsed JSON response, usable as a subsequent SETVALUE value', async () => {
const { host: writeHost, calls } = makeWriteHost();
const { host: netHost, calls: netCalls } = makeNetHost({
'GET https://api/kunde': { ok: true, value: { name: 'Nord' } },
});
await runMacro('r = GET("https://api/kunde") SETVALUE(Umsatz::A1; JSONGET(r; "name"))', makeHost({}), writeHost, netHost);
expect(netCalls).toEqual([{ method: 'GET', url: 'https://api/kunde', body: undefined }]);
expect(calls).toEqual([{ op: 'setValue', args: [[{ tableId: 'Umsatz', col: 0, row: 0 }], 'Nord'] }]);
});
it('POST sends a body and binds the parsed JSON response', async () => {
const { host: writeHost, calls } = makeWriteHost();
const { host: netHost, calls: netCalls } = makeNetHost({
'POST https://api/kunden': { ok: true, value: { id: 42 } },
});
await runMacro(
'r = POST("https://api/kunden"; "{\\"name\\":\\"Nord\\"}") SETVALUE(Umsatz::A1; JSONGET(r; "id"))',
makeHost({}),
writeHost,
netHost,
);
expect(netCalls).toEqual([{ method: 'POST', url: 'https://api/kunden', body: '{"name":"Nord"}' }]);
expect(calls).toEqual([{ op: 'setValue', args: [[{ tableId: 'Umsatz', col: 0, row: 0 }], '42'] }]);
});
it('GET/POST used anywhere other than the whole RHS of a plain assignment fall through to the ordinary path and become #NAME? -- they need a real await, so (unlike JSONGET below) they cannot be embedded in an arbitrary expression', async () => {
const { host: writeHost } = makeWriteHost();
// nested inside an expression -- SETVALUE's error wrapping only
// surfaces the FormulaError *code*, not its detail text, so #NAME?
// (not the literal words "unknown function") is what's actually visible
await expect(
runMacro('SETVALUE(Umsatz::A1; 1 + GET("https://x"))', makeHost({}), writeHost),
).rejects.toThrow(/NAME/);
// inside an IF condition -- an error in a condition resolves the whole
// macro run with that FormulaError as its "value" (same as any other
// ifstmt-condition error, existing behaviour predating this feature),
// it does not reject the promise
const result = await runMacro('IF GET("https://x") = 1 THEN SETVALUE(Umsatz::A1; 1) END', makeHost({}), writeHost);
expect(result).toEqual(new FormulaError('#NAME?', 'unknown function GET'));
});
it('JSONGET, unlike GET/POST, is purely synchronous and works nested anywhere an expression can appear (evalScalar special-case, not just the assignment-RHS form)', async () => {
const { host: writeHost, calls } = makeWriteHost();
const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value: { name: 'Nord' } } });
await runMacro(
'r = GET("https://x") IF JSONGET(r; "name") = "Nord" THEN SETVALUE(Umsatz::A1; JSONGET(r; "name")) END',
makeHost({}),
writeHost,
netHost,
);
expect(calls).toEqual([{ op: 'setValue', args: [[{ tableId: 'Umsatz', col: 0, row: 0 }], 'Nord'] }]);
});
it('a network failure aborts the run cleanly with the failure message', async () => {
const { host: writeHost } = makeWriteHost();
const { host: netHost } = makeNetHost({
'GET https://down': { ok: false, error: new Error('GET https://down: HTTP 500 Internal Server Error') },
});
await expect(runMacro('r = GET("https://down")', makeHost({}), writeHost, netHost)).rejects.toThrow(/HTTP 500/);
});
it('a declined confirmation (surfaced by the host as a rejection) aborts without writing anything', async () => {
const { host: writeHost, calls } = makeWriteHost();
const { host: netHost } = makeNetHost({
'GET https://x': { ok: false, error: new Error("GET: Verbindung zu 'x' abgelehnt") },
});
await expect(
runMacro('r = GET("https://x") SETVALUE(Umsatz::A1; 1)', makeHost({}), writeHost, netHost),
).rejects.toThrow(/abgelehnt/);
expect(calls).toHaveLength(0);
});
});
describe('runMacroActions -- JSON value collapse rule (evalScalar\'s case "json")', () => {
const cases: Array<[unknown, CellValue]> = [
[null, null],
['Nord', 'Nord'],
[42, 42],
[true, 1],
[false, 0],
[{ a: 1 }, '{"a":1}'],
[[1, 2], '[1,2]'],
];
for (const [value, expected] of cases) {
it(`a JSON ${JSON.stringify(value)} used directly as a SETVALUE value collapses to ${JSON.stringify(expected)}`, async () => {
const { host: writeHost, calls } = makeWriteHost();
const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value } });
await runMacro('r = GET("https://x") SETVALUE(Umsatz::A1; r)', makeHost({}), writeHost, netHost);
expect(calls[0].args[1]).toBe(String(expected ?? ''));
});
}
});
describe('runMacroActions -- JSONGET path resolution', () => {
const data = { items: [{ name: 'Nord', tags: ['a', 'b'] }, { name: 'Süd' }], meta: { count: 2 } };
it('resolves a nested object path', async () => {
const { host: writeHost, calls } = makeWriteHost();
const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value: data } });
await runMacro('r = GET("https://x") SETVALUE(Umsatz::A1; JSONGET(r; "meta.count"))', makeHost({}), writeHost, netHost);
expect(calls[0].args[1]).toBe('2');
});
it('resolves an array index within the path', async () => {
const { host: writeHost, calls } = makeWriteHost();
const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value: data } });
await runMacro('r = GET("https://x") SETVALUE(Umsatz::A1; JSONGET(r; "items.0.name"))', makeHost({}), writeHost, netHost);
expect(calls[0].args[1]).toBe('Nord');
});
it('a nested array-of-strings path resolves too', async () => {
const { host: writeHost, calls } = makeWriteHost();
const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value: data } });
await runMacro('r = GET("https://x") SETVALUE(Umsatz::A1; JSONGET(r; "items.0.tags.1"))', makeHost({}), writeHost, netHost);
expect(calls[0].args[1]).toBe('b');
});
it('a missing path resolves to JSON null, not a throw', async () => {
const { host: writeHost, calls } = makeWriteHost();
const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value: data } });
await runMacro('r = GET("https://x") SETVALUE(Umsatz::A1; JSONGET(r; "items.9.name"))', makeHost({}), writeHost, netHost);
expect(calls[0].args[1]).toBe(''); // null collapses to '' via toText(), same as an empty cell
});
it('JSONGET on a non-JSON value throws a clear error when used as an assignment RHS', async () => {
const { host: writeHost } = makeWriteHost();
await expect(
runMacro('x = 5 y = JSONGET(x; "a")', makeHost({}), writeHost),
).rejects.toThrow(/JSON-Ergebnis/);
});
it('JSONGET on a non-JSON value nested in an expression degrades to a formula error (#VALUE!), same as any other evalScalar function misuse', async () => {
const { host: writeHost } = makeWriteHost();
await expect(
runMacro('x = 5 SETVALUE(Umsatz::A1; JSONGET(x; "a"))', makeHost({}), writeHost),
).rejects.toThrow(/VALUE/);
});
});
describe('runMacroActions -- FOR over a JSON array', () => {
it('iterates each element, usable via JSONGET', async () => {
const { host: writeHost, calls } = makeWriteHost();
const { host: netHost } = makeNetHost({
'GET https://x': { ok: true, value: [{ name: 'Nord' }, { name: 'Süd' }] },
});
await runMacro(
'r = GET("https://x") FOR item IN r DO SETVALUE(Umsatz::A1; JSONGET(item; "name")) END',
makeHost({}),
writeHost,
netHost,
);
expect(calls.map((c) => c.args[1])).toEqual(['Nord', 'Süd']);
});
it('FOR over a JSON object (not an array) throws a clear error', async () => {
const { host: writeHost } = makeWriteHost();
const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value: { a: 1 } } });
await expect(
runMacro('r = GET("https://x") FOR item IN r DO SETVALUE(Umsatz::A1; 1) END', makeHost({}), writeHost, netHost),
).rejects.toThrow(/Array/);
});
it('FOR over a JSON primitive throws the same clear error', async () => {
const { host: writeHost } = makeWriteHost();
const { host: netHost } = makeNetHost({ 'GET https://x': { ok: true, value: 5 } });
await expect(
runMacro('r = GET("https://x") FOR item IN r DO SETVALUE(Umsatz::A1; 1) END', makeHost({}), writeHost, netHost),
).rejects.toThrow(/Array/);
});
it('a JSONGET(...) call used directly as the FOR source (not first bound to a variable) parses and runs -- found live: the shared FOR-source shape check did not originally know about this new source kind', async () => {
const { host: writeHost, calls } = makeWriteHost();
const { host: netHost } = makeNetHost({
'GET https://x': { ok: true, value: { items: [{ name: 'Nord' }, { name: 'Süd' }] } },
});
await runMacro(
'r = GET("https://x") FOR item IN JSONGET(r; "items") DO SETVALUE(Umsatz::A1; JSONGET(item; "name")) END',
makeHost({}),
writeHost,
netHost,
);
expect(calls.map((c) => c.args[1])).toEqual(['Nord', 'Süd']);
});
});
+4
View File
@@ -40,6 +40,10 @@ function sexpr(n: Node): string {
return `blockcall(${n.label})`;
case 'omitted':
return 'omitted';
case 'json':
// never produced by the parser (see parser.ts's comment on
// JsonLiteralNode) -- exhaustiveness only, no test exercises this.
return `json(${JSON.stringify(n.value)})`;
}
}
const parse = (s: string) => sexpr(parseFormula(s));
+294 -7
View File
@@ -39,11 +39,19 @@
* ordinary function call this can't be resolved any earlier.
*/
import type { ActionNode, BlockCallNode, BlockNode, CellNode, FuncNode, NamedRefNode, Node, RangeNode, Statement } from './parser';
import type { ActionNode, BlockCallNode, BlockNode, CellNode, FuncNode, JsonLiteralNode, NamedRefNode, Node, RangeNode, Statement } from './parser';
import { getFunction, isTruthy } from './functions';
import { expandRange } from './refs';
import { substitute } from './userFunctions';
import { FormulaError, isError, type CellRef, type CellValue, type EngineHost, type MacroWriteHost } from './types';
import {
FormulaError,
isError,
type CellRef,
type CellValue,
type EngineHost,
type MacroNetworkHost,
type MacroWriteHost,
} from './types';
export function evaluate(ast: Node, host: EngineHost): CellValue {
return evalScalar(ast, host);
@@ -135,6 +143,24 @@ function evalScalar(node: Node, host: EngineHost): CellValue {
// argument evaluated lazily -- both require handling here, before the
// generic eager/flattening dispatch below.
if (upperName === 'XLOOKUP') return evalXLookup(node, host);
// JSONGET needs its first argument RAW (a JsonLiteralNode, never
// reducible to a plain CellValue first) -- special-cased here for the
// same reason as IF/XLOOKUP. Unlike GET/POST (see execStatementAsync
// in the "async macro interpreter" section below), JSONGET is pure
// computation with no `await` anywhere, so it can live in this
// ordinary, fully-synchronous dispatch and therefore compose in ANY
// expression position (nested in SETVALUE's value, an IF condition,
// ...) -- not just as the whole RHS of a macro assignment. By the
// time evalScalar ever sees `node.args[0]`, evalInEnv has already run
// substitute() against the current env, so a param/loop-variable
// bound to a JsonLiteralNode already appears here as that literal
// node directly, no env access needed. Always collapses its result to
// a plain CellValue (evalScalar's return type leaves no other
// choice) -- a macro that wants to keep drilling into/iterating a
// JSON *structure* instead uses JSONGET as a whole assignment RHS
// (`x = JSONGET(r; "items")`), which the async interpreter's
// `evalSpecialAssign` intercepts before ever reaching here.
if (upperName === 'JSONGET') return evalJsonGet(node, host);
const fn = getFunction(node.name);
if (!fn) return new FormulaError('#NAME?', `unknown function ${node.name}`);
@@ -167,9 +193,55 @@ function evalScalar(node: Node, host: EngineHost): CellValue {
// "evaluates to null" checks the raw arg Node's kind directly before
// ever calling evalScalar on it (see evalXLookup below).
return null;
case 'json':
// A JSON value (a GET/POST result, or a JSONGET/FOR-over-array
// element) used in ordinary scalar position -- e.g. `SETVALUE(x;
// result)` without first drilling into it via JSONGET. Collapses to
// the best plain CellValue: a primitive keeps its own type/value
// (boolean -> 1/0, matching this engine's existing convention that
// comparisons already return 1/0 rather than a real boolean type);
// an object/array has no better scalar form than its JSON text.
return jsonValueToCellValue(node.value);
}
}
/** {@link evalScalar}'s `case 'json'` collapse rule -- a plain JSON
* primitive becomes the matching `CellValue`; an object/array has no
* better scalar form than its JSON text. Factored out as its own
* function so there is exactly one definition of this rule. */
function jsonValueToCellValue(value: unknown): CellValue {
if (value === null || value === undefined) return null;
if (typeof value === 'number') return value;
if (typeof value === 'string') return value;
if (typeof value === 'boolean') return value ? 1 : 0;
return JSON.stringify(value);
}
/** `JSONGET(value; path)` used nested in an ordinary expression (see the
* `case 'func'` special-case above) -- `value` must already be a
* `JsonLiteralNode` by the time it reaches here (produced earlier in the
* same macro by `GET`/`POST`/another `JSONGET`/a `FOR`-over-array
* iteration); anything else is a `#VALUE!`, the same error class any
* other function-argument type mismatch gets in this engine. `path`
* resolves through the ordinary `evalScalar`, so it can itself be a
* computed/concatenated string. Uses `jsonGetPath` (defined alongside the
* async macro interpreter below, since that is `jsonGetPath`'s only other
* caller) -- a missing path resolves to JSON `null`, not an error, see
* its own doc comment. */
function evalJsonGet(node: FuncNode, host: EngineHost): CellValue {
if (node.args.length !== 2) return new FormulaError('#ERR', 'JSONGET expects 2 arguments');
const target = node.args[0];
if (target.kind !== 'json') {
return new FormulaError('#VALUE!', 'JSONGET: first argument is not a JSON result');
}
const path = evalScalar(node.args[1], host);
if (isError(path)) return path;
const pathText = toText(path);
if (isError(pathText)) return pathText;
return jsonValueToCellValue(jsonGetPath(target.value, pathText));
}
/** IF(cond; then; else?) -- the real, short-circuiting implementation (see
* the `case 'func'` special-case above and functions.ts's FUNCTIONS.IF
* comment). Only `node.args[0]` is always evaluated; whichever of the
@@ -375,19 +447,40 @@ function evaluateBlockCall(node: BlockCallNode, host: EngineHost): CellValue {
* `evaluateBlockCall`, no RETURN is required: falling off the end simply
* means "done", returning `null` (a macro has no caller expecting a value;
* RETURN is still usable for an early exit / a status message, see the
* `'return'` case in `execStatement`). `writeHost` is what makes the new
* `'action'` statement kind (SETVALUE/INSERTROW/...) possible -- see its
* doc comment in types.ts. `host` MUST be a proxy that re-resolves against
* `'return'` case in `execStatementAsync`). `writeHost` is what makes the
* `'action'` statement kind (SETVALUE/INSERTROW/...) possible; `netHost` is
* what makes the `GET`/`POST` assignment form possible -- see their doc
* comments in types.ts. `host` MUST be a proxy that re-resolves against
* the live engine on every call, never a cached reference: a structural
* action mid-run (INSERTROW, ADDTABLE, ...) replaces the underlying engine
* instance wholesale (see store/document.ts's `syncWorkbook`), and a cached
* reference would silently go stale for every read after that point.
*
* `async` because `GET`/`POST` genuinely need to wait on a real network
* round-trip -- but this is a SEPARATE interpreter (`execBlockAsync`/
* `execStatementAsync`, below) from the synchronous `execBlock`/
* `execStatement` that `evaluateBlockCall` (ordinary `kind:'block'` custom
* functions) still uses. That path is reachable from deep inside the
* fully-synchronous recalculation pass (`evalScalar`'s `case 'blockcall'`,
* itself called throughout `workbook.ts`) and must never become async --
* so rather than convert the shared interpreter and risk that cascade, the
* two statement-kind switches are two separate (structurally near-
* identical) functions. Everything they call beneath the switch itself
* (`evalInEnv`, `resolveWriteTarget`, `rangeOrCellToCellNodes`, `execAction`)
* is pure/synchronous and fully shared -- only the outer dispatch loop and
* the `'assign'`/`'for'` cases (which need to recognize a network/JSON
* call, or a JSON-array source) are duplicated.
*/
export function runMacroActions(block: BlockNode, host: EngineHost, writeHost: MacroWriteHost): CellValue | null {
export async function runMacroActions(
block: BlockNode,
host: EngineHost,
writeHost: MacroWriteHost,
netHost: MacroNetworkHost,
): Promise<CellValue | null> {
const env = new Map<string, Node>();
const budget: Budget = { steps: 0, limit: MAX_MACRO_STEPS };
try {
execBlock(block, env, host, budget, writeHost);
await execBlockAsync(block, env, host, budget, writeHost, netHost);
} catch (e) {
if (e instanceof ReturnSignal) return e.value;
throw e;
@@ -480,6 +573,200 @@ function execStatement(
}
}
// ---------------------------------------------------------------------------
// Async macro interpreter -- ONLY reachable via `runMacroActions`, never via
// `evaluateBlockCall` (see that function's own doc comment for why the sync
// interpreter above must stay untouched). Structurally mirrors
// `execBlock`/`execStatement` case-for-case; the only real differences are
// the `'assign'` case's GET/POST/JSONGET special-casing and the `'for'`
// case's JSON-array branch. Everything else delegates to the exact same
// synchronous helpers (`evalInEnv`, `resolveWriteTarget`,
// `rangeOrCellToCellNodes`, `execAction`) the sync interpreter already uses.
// ---------------------------------------------------------------------------
const NETWORK_OR_JSON_NAMES = new Set(['GET', 'POST', 'JSONGET']);
/** True iff `node` is exactly `NAME(args)` for one of GET/POST/JSONGET --
* the only shape `execStatementAsync`'s `'assign'` case treats specially.
* Used anywhere else (nested in an expression, an IF condition, a
* function argument) these names fall through to the ordinary, unchanged
* `evalScalar` -> `getFunction()` path and become `#NAME?`, since they are
* deliberately never registered in functions.ts's builtin table -- the
* same containment mechanism the codebase already relies on for the 11
* write actions. */
function isNetworkOrJsonCall(node: Node): node is FuncNode {
return node.kind === 'func' && NETWORK_OR_JSON_NAMES.has(node.name.toUpperCase());
}
/** Resolve a dot-separated path through a JSON value -- a numeric segment
* indexes an array, any other segment indexes an object key. A missing
* path (wrong type at some segment, out-of-range index, unknown key)
* resolves to JSON `null` rather than throwing, so a macro can safely
* probe "does this field exist" via a subsequent comparison/IF instead of
* needing its own existence-check syntax. */
function jsonGetPath(value: unknown, path: string): unknown {
let cur = value;
const trimmed = path.trim();
if (trimmed === '') return cur;
for (const segment of trimmed.split('.')) {
if (cur === null || cur === undefined) return null;
if (Array.isArray(cur)) {
const idx = Number(segment);
if (!Number.isInteger(idx) || idx < 0 || idx >= cur.length) return null;
cur = cur[idx];
} else if (typeof cur === 'object') {
cur = (cur as Record<string, unknown>)[segment] ?? null;
} else {
return null; // trying to drill into a string/number/boolean
}
}
return cur;
}
/** Resolves a `JSONGET(value; path)` call to the `JsonLiteralNode` it
* produces -- WITHOUT collapsing it to a plain `CellValue` (unlike
* `evalJsonGet` above, used when JSONGET is nested in ordinary expression
* position). Two callers need this uncollapsed form: `evalSpecialAssign`
* (`x = JSONGET(...)`, preserving structure for further drilling/
* iteration) and `execStatementAsync`'s `'for'` case (`FOR item IN
* JSONGET(r; "items") DO ...` -- a JSONGET call used directly as the FOR
* source, not first bound to a variable; found live: `substitute()` alone
* never evaluates a function call, so without this the source would still
* be an unresolved `FuncNode` by the time the array-ness check runs). */
function resolveJsonGetCall(node: FuncNode, env: Map<string, Node>, host: EngineHost): JsonLiteralNode {
if (node.args.length !== 2) {
throw new Error(`JSONGET: erwartet 2 Argumente, ${node.args.length} übergeben`);
}
const target = substitute(node.args[0], env);
if (target.kind !== 'json') {
throw new Error('JSONGET: das erste Argument muss ein JSON-Ergebnis sein (z. B. von GET/POST/JSONGET)');
}
const path = requireString(evalInEnv(node.args[1], env, host), 'JSONGET');
return { kind: 'json', value: jsonGetPath(target.value, path) };
}
/** Evaluates a `GET`/`POST`/`JSONGET` call (already confirmed by
* `isNetworkOrJsonCall`) and returns the `JsonLiteralNode` to bind the
* assigned name to. `JSONGET` is purely synchronous (no `await` inside),
* kept in this async function anyway so the one `isNetworkOrJsonCall`
* check in `execStatementAsync` covers all three names uniformly. */
async function evalSpecialAssign(
node: FuncNode,
env: Map<string, Node>,
host: EngineHost,
netHost: MacroNetworkHost,
): Promise<JsonLiteralNode> {
const upper = node.name.toUpperCase();
if (upper === 'JSONGET') return resolveJsonGetCall(node, env, host);
const isPost = upper === 'POST';
if (isPost ? node.args.length !== 2 : node.args.length !== 1) {
throw new Error(`${upper}: erwartet ${isPost ? 2 : 1} Argument${isPost ? 'e' : ''}, ${node.args.length} übergeben`);
}
const url = requireString(evalInEnv(node.args[0], env, host), upper);
const body = isPost ? requireString(evalInEnv(node.args[1], env, host), upper) : undefined;
const value = await netHost.request(isPost ? 'POST' : 'GET', url, body);
return { kind: 'json', value };
}
async function execBlockAsync(
block: BlockNode,
env: Map<string, Node>,
host: EngineHost,
budget: Budget,
writeHost: MacroWriteHost,
netHost: MacroNetworkHost,
): Promise<void> {
for (const stmt of block.statements) {
await execStatementAsync(stmt, env, host, budget, writeHost, netHost);
}
}
async function execStatementAsync(
stmt: Statement,
env: Map<string, Node>,
host: EngineHost,
budget: Budget,
writeHost: MacroWriteHost,
netHost: MacroNetworkHost,
): Promise<void> {
switch (stmt.kind) {
case 'assign': {
const substituted = substitute(stmt.value, env);
if (isNetworkOrJsonCall(substituted)) {
if (substituted.name.toUpperCase() !== 'JSONGET') {
// GET/POST count toward the step budget like any other action
// (a real network round-trip); JSONGET is pure computation, same
// treatment as an ordinary assignment.
budget.steps++;
if (budget.steps > budget.limit) {
throw new Error(`Makro führt zu viele Schritte aus (> ${budget.limit} insgesamt)`);
}
}
env.set(stmt.name.toUpperCase(), await evalSpecialAssign(substituted, env, host, netHost));
return;
}
const v = evalScalar(substituted, host);
if (isError(v)) throw new ReturnSignal(v);
env.set(stmt.name.toUpperCase(), literalNodeOf(v));
return;
}
case 'return': {
const v = evalInEnv(stmt.value, env, host);
throw new ReturnSignal(v);
}
case 'ifstmt': {
const c = evalInEnv(stmt.cond, env, host);
if (isError(c)) throw new ReturnSignal(c);
if (isTruthy(c)) await execBlockAsync(stmt.then, env, host, budget, writeHost, netHost);
else if (stmt.else) await execBlockAsync(stmt.else, env, host, budget, writeHost, netHost);
return;
}
case 'for': {
let source = substitute(stmt.source, env);
// A JSONGET(...) call used directly as the FOR source (not first
// bound to a variable) is still an unresolved FuncNode at this point
// -- substitute() only resolves name references, it never evaluates
// a call. Resolve it here to the JsonLiteralNode it produces before
// the array-ness check below.
if (source.kind === 'func' && source.name.toUpperCase() === 'JSONGET') {
source = resolveJsonGetCall(source, env, host);
}
if (source.kind === 'json') {
if (!Array.isArray(source.value)) {
throw new Error(`FOR ${stmt.varName} IN ...: JSON-Quelle muss ein Array sein, kein Objekt/Einzelwert`);
}
for (const item of source.value) {
budget.steps++;
if (budget.steps > budget.limit) {
throw new Error(`FOR-Schleife(n) über zu viele Elemente (> ${budget.limit} insgesamt)`);
}
env.set(stmt.varName.toUpperCase(), { kind: 'json', value: item });
await execBlockAsync(stmt.body, env, host, budget, writeHost, netHost);
}
return;
}
const cells = rangeOrCellToCellNodes(source, stmt.varName, host);
for (const cell of cells) {
budget.steps++;
if (budget.steps > budget.limit) {
throw new Error(`FOR-Schleife(n) über zu viele Zellen (> ${budget.limit} insgesamt)`);
}
env.set(stmt.varName.toUpperCase(), cell);
await execBlockAsync(stmt.body, env, host, budget, writeHost, netHost);
}
return;
}
case 'action': {
budget.steps++;
if (budget.steps > budget.limit) {
throw new Error(`Makro führt zu viele Schritte aus (> ${budget.limit} insgesamt)`);
}
execAction(stmt, env, host, writeHost);
return;
}
}
}
/** Dispatches one macro write-action call by name -- see types.ts's
* `MacroWriteHost` for the full vocabulary and engine/README.md's
* "Makros" section for the syntax. Only `SETVALUE`'s first argument is
+1
View File
@@ -71,5 +71,6 @@ export {
type Scalar,
type EngineHost,
type MacroWriteHost,
type MacroNetworkHost,
type NamedRangeDef,
} from './types';
+17 -1
View File
@@ -105,7 +105,8 @@ export type Node =
| NamedRefNode
| ParamNode
| BlockCallNode
| OmittedNode;
| OmittedNode
| JsonLiteralNode;
export interface NumberNode {
kind: 'number';
@@ -193,6 +194,19 @@ export interface ParamNode {
export interface OmittedNode {
kind: 'omitted';
}
/** A JSON value flowing through a macro's `env` (a `GET`/`POST` response, a
* `JSONGET` result, or one element while a `FOR` iterates a JSON array) --
* see evaluator.ts's `execStatementAsync`/`jsonGetPath`. Never produced by
* this parser (there is no JSON-literal syntax in the language) -- only
* the macro interpreter ever constructs one, wraps it in `literalNodeOf`-
* style env binding, and hands it back through `substitute()` like any
* other bound name. `evalScalar`'s `case 'json'` is where it collapses to
* an ordinary `CellValue` if used in plain scalar position without first
* drilling into it via `JSONGET`. */
export interface JsonLiteralNode {
kind: 'json';
value: unknown;
}
// ---------------------------------------------------------------------------
// "Block" function bodies (`kind: 'block'` custom functions) -- a small
@@ -794,6 +808,8 @@ export function collectRefs(
node.args.forEach((a) => collectRefs(a, out));
collectRefsInBlock(node.block, out);
break;
case 'json':
break; // a JSON value carries no cell/range/named-range refs of its own
}
return out;
}
+24
View File
@@ -179,3 +179,27 @@ export interface MacroWriteHost {
renameSheet(sheetRef: string, newName: string): void;
deleteSheet(sheetRef: string): void;
}
/**
* The document's NETWORK surface, as seen by a running macro's `GET`/`POST`
* assignment form (`x = GET(url)` / `x = POST(url; body)`; see evaluator.
* ts's `execStatementAsync`). Kept separate from {@link MacroWriteHost} for
* the same reason that one is kept separate from {@link EngineHost}: this
* engine module stays framework/DOM-agnostic (no `fetch`, no browser APIs
* anywhere in `src/engine/`) -- the store (`document.ts#runMacro`) is the
* only implementer, and it's the one place a network confirmation prompt
* and the actual `fetch()` call belong.
*
* A single `request()` method (not separate `get`/`post` methods) because
* both share the exact same "confirm this host once, then fetch and parse
* JSON" flow in the implementation -- only the HTTP method and whether a
* body is sent differ.
*/
export interface MacroNetworkHost {
/** Resolves with the parsed JSON response body, or rejects with a plain
* `Error` (network failure, a non-2xx status, a response that isn't
* valid JSON, or the user declining a host-confirmation prompt) --
* caught the same way as any other macro action failure, aborting the
* run and surfacing the message as the result's `.message`. */
request(method: 'GET' | 'POST', url: string, body?: string): Promise<unknown>;
}
+14 -1
View File
@@ -115,6 +115,7 @@ function expand(
case 'namedref':
case 'param':
case 'omitted':
case 'json':
return node; // leaves -- nothing to expand
case 'unary':
return { ...node, operand: expand(node.operand, index, bodyCache, blockCache, depth) };
@@ -203,6 +204,7 @@ export function substitute(node: Node, bound: ReadonlyMap<string, Node>): Node {
case 'range':
case 'namedref':
case 'omitted':
case 'json':
return node;
case 'unary':
return { ...node, operand: substitute(node.operand, bound) };
@@ -328,7 +330,7 @@ export function declaredBlockNames(block: BlockNode, out: string[] = []): string
export function findInvalidForSource(block: BlockNode): ForNode | null {
for (const stmt of block.statements) {
if (stmt.kind === 'for') {
if (!isReferenceShaped(stmt.source)) return stmt;
if (!isReferenceShaped(stmt.source) && !isJsonGetCall(stmt.source)) return stmt;
const nested = findInvalidForSource(stmt.body);
if (nested) return nested;
} else if (stmt.kind === 'ifstmt') {
@@ -348,6 +350,17 @@ function isReferenceShaped(node: Node): boolean {
return node.kind === 'param' || node.kind === 'cell' || node.kind === 'range' || node.kind === 'namedref';
}
/** A `FOR` source may also be a `JSONGET(...)` call -- once substituted and
* evaluated it may resolve to a JSON array (see evaluator.ts's
* `execStatementAsync` `'for'` case), a possibility `isReferenceShaped`
* above doesn't cover since it's a genuinely new FOR-source *kind*, not a
* reference. Not shared with `findInvalidActionTarget`'s SETVALUE-target
* check -- a JSONGET result is a VALUE, never a place to write to, so a
* write target must stay reference-shaped only. */
function isJsonGetCall(node: Node): boolean {
return node.kind === 'func' && node.name.toUpperCase() === 'JSONGET';
}
/** {@link findInvalidForSource}'s counterpart for a macro's `SETVALUE`
* target (its first argument) instead of a FOR source -- same reasoning,
* checked at save time by store/document.ts#validateMacro. */