Parsware
كل المقالات

Parsware Platform

One script, two interpreters

BS Lang runs in two places. A form rule runs in the browser, in TypeScript; a plugin step or a Custom API runs on the server, in C#. Those are two separate interpreters of one language, so we keep them the same with one shared set of test scripts that both must pass. This is a developer's look at how that works, and at the two cases where they still disagree.

هذا المقال متاح بالإنجليزية فقط.

The Claim limits solution script in the BS editor, which says it can be called from a rule, a command, a plugin step or a Custom API, next to the title "One script, two interpreters"

This one is for developers. It explains something you don't normally have to think about, and why you can rely on it.

Two places a script runs

BS Lang runs in the browser and on the server:

In the browser (TypeScript) On the server (C#)
Form rules Plugin steps
Command buttons Custom APIs
Studio screens Scheduled jobs
Processes

The browser side needs no round trip, so a field can appear the moment you type. The server side is the one that can't be skipped: it runs however the record was saved.

And one script can reach both. A solution script is imported by whatever needs it: a form rule in the browser, a plugin step on the server. The editor says so above the code:

The Claim limits solution script in the editor. Above the code: Call it from a rule, a command, a plugin step or a Custom API with: import 'par_claim_limits';

So the same needsApproval(amount) can decide what a form shows and what the server accepts. That only works if both sides agree on what the script means.

Two implementations of one language

Underneath, there are two interpreters: one written in TypeScript, one in C#. Both are built from the same grammar, so they read a script the same way. What they do with it is written twice.

Two implementations drift. Not on purpose, and not all at once, but a fix goes into one and not the other, and nothing in either compiler notices. The result is the worst kind of bug: a rule that shows a field on the form and then refuses the save, or a condition that works in one place and branches the other way in another.

One set of tests, run by both

So there's a conformance suite: a folder of test scripts, each with the values it must produce. Both interpreters run the same files. Neither side owns them, so a case added for one is added for both.

Each case is a script and what it must record:

A case from 06-equality.json: name "different kinds are never equal, and never an error", a script recording 1 == "1", true == 1 and "" == 0, and three expected values, each type bool, value false

record(v) is a function both test runners give the script. It notes the type and the value of whatever it's handed. The comparison is on what a script can tell apart, the type and the value, not on how each side stores it inside.

int and number are separate types on purpose. 1 is an int and 1.0 is a number in both, so a case that records 1 / 2 is also checking which one the language gives you.

There are 131 cases in eight files: arithmetic, strings, control flow, functions and closures, lists and objects, equality, the execution budget, and variables.

Both sides, green

The TypeScript side runs them with Vitest:

The Vitest output for the equality suite: ten cases, each with a green tick, from two booleans compare to a comparison is a value, not only a condition. Then: Test Files 1 passed; Tests 132 passed, 2 skipped

And the C# side with xUnit, reading the very same files:

The dotnet test output for the same ten equality cases, each Passed, then Total tests 131, Passed 131

(The totals differ because each side also has a couple of checks of its own, like one that fails if the case files can't be found, so an empty run can't pass by accident.)

What the suite has caught

The equality file exists because the two sides disagreed. true == true was false in the browser, silently, and an error on the server. A comparison with an empty value was never true in the browser, so a sample rule that should have filled in a default currency did nothing at all.

The fix was one rule, written down as cases: nothing equals nothing, values compare within their kind, different kinds are never equal, and none of that is an error. Now both sides pass it, and a change that breaks it fails the build on whichever side broke it.

Where they still disagree

Some cases are marked pending: a known difference, with the reason written next to it. Both runners skip them and list them, so the suite stays green and the list stays visible. There are two today. This is the one that matters:

A pending case: record(0.1 + 0.2) expecting number 0.3, with the reason: DIVERGENCE. The C# interpreter's number is System.Decimal and gives exactly 0.3; the TypeScript interpreter's is an IEEE-754 double and gives 0.30000000000000004. It is observable to a maker's script: a rule comparing a computed total to a literal can pass on the server and fail on the form.

0.1 + 0.2 is exactly 0.3 on the server and 0.30000000000000004 in the browser, because the two runtimes use different kinds of number. That's a real difference a script can see. Until the language picks one kind of number, compare money with a tolerance, or round first, rather than testing a computed total for exact equality.

The other pending case is a bug on the browser side: reverse() on a list isn't found. It's listed so it can't be forgotten.

The rules we follow

  • A behaviour that isn't in the suite isn't a promise. If you rely on it, it should be a case.
  • A change to either interpreter lands with a case, run on both sides.
  • A difference found in the wild becomes a case first, then a fix.
  • The TypeScript interpreter is the reference, unless it's the one that's wrong.

One known limit: the suite checks that a script was refused and roughly why, not the exact error code, because the C# side doesn't pass the code through yet.

Try it

The cases are in ui/packages/bs-lang/conformance/cases. Run npm run test -w ui/packages/bs-lang and dotnet test platform/tests/Parsware.BsLang.Tests, and add a case of your own to both by adding it once.

Next: business logic in Parsware: which one to use.