ploson v3.1.6
🥋 ploson
Programming Language On Serializable Object Notation (JSON)
Build your own DSL on JSON syntax to store safe executable code in a database.
There are many "JSON Lisp" type of packages, but they're often too narrow in functionality and have a lengthy and arduous syntax meant to be generated rather than manually written. The point with these type of modules is often to enable domain specific languages (DSL) with a DB friendly syntax (JSON) - and so is Ploson. But it's:
- Human developer friendly (made to easily write by hand).
- Supporting asynchronous, also concurrent.
- Customizable regarding available environment identifiers.
- Build your own API/DSL.
- Secure.
eval& other unsafe constructs are forbidden and impossible to access.
- Functional, with a Lisp-like syntax suitable for JSON.
- Easy to learn, yet powerful.
- Implemented with a plugin based system.
- Thoroughly tested.
- 100% test coverage for the default plugin setup.
Getting started
Recommended basic setup:
import { createRunner, defaultEnv } from 'ploson';
import * as R from 'ramda';
const ploson = createRunner({
staticEnv: { ...defaultEnv, R },
});Now run the created ploson parser:
await ploson(['R.map', ['R.add', 3], ['of', 3, 4, 5]]);
// -> [6, 7, 8]Utilize some built-in features to get more control and readability:
await ploson([
',',
{
data: ['of', 3, 4, 5],
func: ['R.map', ['R.add', 3]],
},
['$func', '$data'],
]);
// -> [6, 7, 8]Ploson does not include data processing utilities since Ramda is perfect to add to Ploson in order to be able to build any pure function only through functional composition. Operators are also not included in Ploson, because they are sometimes flawed, not fit for FP, and suitably implemented in Ramda. More on this below.
Primitives
await ploson(2); // -> 2
await ploson(3.14); // -> 3.14
await ploson(true); // -> true
await ploson(null); // -> null
await ploson(undefined); // -> undefinedString Syntax
await ploson('`hello world`'); // -> "hello world"Would be considered an identifier without the backticks.
Array Syntax: Calling Functions
With Ramda added, run like:
await ploson(['R.add', 1, 2]); // -> 3Easily create arrays with built in of method:
await ploson(['of', 1, true, '`foo`']); // -> [1, true, "foo"]Create a Date object with time now (requires defaultEnv):
await ploson(['newDate']); // -> Mon Jul 05 2021 19:41:35 GMT+0200 (Central European Summer Time)Nest calls in a classic FP manner:
await ploson(['R.add', 7, ['Math.floor', ['R.divide', '$myAge', 2]]]);
// -> Minimum acceptable partner age?Empty Array
An empty array is not evaluated as a function, but left as is. This means that there are 2 ways to define an empty array:
await ploson([]); // -> []
await ploson(['of']); // -> []Object Syntax
- Returns the object, evaluated
- Will put key/value pairs in the variable scope as an automatic side effect
- Parallel async
Let's break these points down
It returns the object:
await ploson({ a: 1 }); // -> { a: 1 }...evaluated:
await ploson({ a: '`hello world`' }); // -> { a: "hello world" }
await ploson({ a: ['of', 1, 2, 3] }); // -> { a: [1, 2, 3] }Keys & values will be automatically put in a per-parser variable scope, and accessed with prefix $:
await ploson({ a: '`foo`', b: '`bar`' }); // VARS: { a: "foo", b: "bar" }
await ploson({ a: ['of', 1, 2, '$b'] }); // VARS: { a: [1, 2, "bar"], b: "bar" }Objects are treated as if its members were run with Promise.all — Async & in parallel:
await ploson({ user: ['fetchProfile', '$uId'], conf: ['fetchConfiguration'] });
// VARS: { user: <result-from-fetchProfile>, conf: <result-from-fetchConfiguration> }Note: Each object value is
awaited which means that it doesn't matter if it's a promise or not, becauseawait 5is evaluated to5in JavaScript.
Adding the use of our amazing comma operator , (see Built-in functions below), we can continue sequentially after that parallel async work:
await ploson([
',',
{ user: ['fetchProfile', '$uId'], conf: ['fetchConfiguration'] }, // parallell async
{ name: ['R.propOr', '$conf.defaultName', '`name`', '$user'] },
]);
// -> { name: 'John Doe' }If the same parser would be used for all of the examples above in this section, the variable scope for that parser would now contain (of course depending on what the fetcher functions return):
{
a: [1, 2, "bar"],
b: "bar",
user: { name: "John Doe", /*...*/ },
conf: { defaultName: "Noname", /*...*/ },
name: "John Doe",
}The Static Environment (available functions)
Plugins Built-in Functions
Plugins add functions to the static environment scope (that you will get even without providing anything to staticEnv for the parser creation).
You would have to override these if you want to make them unavailable.
envPlugin
| Function name | Implementation | Comment |
|---|---|---|
of | Array.of | We should always be able to create arrays. |
, | R.pipe(Array.of, R.last) | Evaluate the arguments and simply return the last one. (see JS's comma operator) |
void | () => undefined | Could be used like ,, if no return value is desired. |
varsPlugin
| Function name | Arguments |
|---|---|
getVar | string (path) |
setVar | string (path), any (value) |
The above functions are alternatives to $ prefix and object setting respectively.
The Available defaultEnv
As seen in the example under "Getting Started" above, we can add defaultEnv to staticEnv to populate the environment scope with a bunch of basic JavaScript constructs. defaultEnv includes:
| Function name | Comment |
|---|---|
undefined | |
console | Access native console API. |
Array | Access native Array API. |
Object | Access native Object API. |
String | Access native String API. |
Number | Access native Number API. |
Boolean | Access native Number API. |
Promise | Access native Promise API. |
newPromise | Create Promises. |
Date | Access native Date API. |
newDate | Create Dates. |
Math | Access native Math API. |
parseInt | The native parseInt function. |
parseFloat | The native parseFloat function. |
Set | Access native Set API. |
Map | Access native Map API. |
newSet | Create Sets. |
newMap | Create Maps. |
RegExp | Create RegExps & access RegExp API. |
fetch | The fetch function. Works in both Node & Browser |
Operators, Type Checks etc.
Ploson is not providing any equivalents to JavaScript's operators. Firstly because they have to be functions and so operators makes no full sense in a lisp-like language. Secondly because many JavaScript operators are problematic in implementation and sometimes not suitable, or ambiguous, for functional style and composition (if simply lifted into functions). Lastly because Ramda provides alternatives for all of the necessary operators, better implemented and suitable for functional language syntax. You could make aliases for these if you absolutely want, and that would start out something like this:
{
'>': R.gt,
'<': R.lt,
'<=': R.lte,
'>=': R.gte,
'==': R.equals,
'!=': R.complement(R.equals),
'!': R.not,
}For type checking I personally think that lodash has the best functions (all is*). There is also the library is.
For any date processing, the fp submodule of date-fns is recommended.
Ramda Introduction
Ramda is a utility library for JavaScript that builds on JS's possibilities with closures, currying (partially applying functions), first rate function values, etc. It provides a system of small generic composable functions that can, through functional composition, be used to build any other pure data processing function. This truly supercharges JavaScript into declarative expressiveness and immutability that other languages simply can not measure up to.
Ramda is the perfect tool belt for Ploson.
The ability to build any function by only composing Ramda functions means never having to specify another function head (argument parenthesis) ever again (this is a stretch, but possible). Here is an example:
// ES6+ JavaScript:
const reducer = (acc, item = {}) =>
item && item.x ? { ...acc, [item.x]: (acc[item.x] || 0) + 1 } : acc;
// "The same function" with Ramda:
const reducer = R.useWith(R.mergeWith(R.add), [
R.identity,
R.pipe(
R.when(R.complement(R.is(Object)), R.always({})),
R.when(R.has('x'), R.pipe(R.prop('x'), R.objOf(R.__, 1))),
),
]);This can feel complex and limiting, so Ploson provides a "lambda" or "arrow function" syntax.
Lambda/Arrow Function Syntax =>
A lambdaPlugin provides an arrow function syntax.
await ploson(['=>', ['x'], ['R.add', 3, '$x']]);would be the same as x => x + 3 in JS.
The above example is somewhat unrealistic since you would simplify it to ['R.add', 3]
Any single argument function is easier written with only Ramda and does not require this lambda syntax.
A more realistic example is when you need a reduce iterator function (2 arguments), perhaps also with some default value for one of the arguments:
await ploson([
'=>',
['acc', ['x', 1]],
['R.append', ['R.when', ['R.gt', 'R.__', 4], ['R.divide', 'R.__', 2], '$x'], '$acc'],
]);Inside a lambda function:
- Arguments share the same variable scope as all other variables (outside the function).
- No local variables possible.
- Object syntax is synchronous, it will not have any async behaviour as it has outside a lambda.
The lambdaPlugin requires envPlugin & varsPlugin (and doesn't make sense without evaluatePlugin).
Lambda Function Shorthands
Examples that highlight special cases of arrow syntax:
await ploson(['=>']); // -> () => undefined, Same as 'void'
await ploson(['=>', 'x', '$x']); // -> (x) => x, The identity function
await ploson(['=>', 'Math.PI']); // -> () => Math.PIThe last example means that it is possible to leave out arguments if the function should not have any.
Security
Blocked identifiers:
- eval
- Function
- constructor
- setTimeout, setInterval
The Function constructor is similar to the eval function in that it accepts a string that is evaluated as code. The timer functions as well.
These identifiers are forbidden even if they are added to the environment scope.
Customizing Default Plugins
varsPlugin
The constructor of the varsPlugin accepts:
| Parameter | Type | Default | Comment |
|---|---|---|---|
prefix | string | $ | |
vars | Object | {} | The parser variable scope. Will be mutated. |
To customize the varsPlugin with above parameters, one has to explicitly define the list of plugins (the order matters):
import {
createRunner,
defaultEnv,
lambdaPlugin,
envPlugin,
evaluatePlugin,
varsPlugin,
} from 'ploson';
import * as R from 'ramda';
const ploson = createRunner({
staticEnv: { ...defaultEnv, R },
plugins: [
lambdaPlugin(),
envPlugin(),
evaluatePlugin(),
varsPlugin({ vars: { uId: 'john@doe.ex' }, prefix: '@' }),
],
});If you only want to initialize the variable scope however, instead of having to import all plugins and define the plugins property, you could simply do this directly after creation of the parser:
await ploson({ uId: '`john@doe.ex`' });Yet another way to get this uId value into a parser is of course to add it to staticEnv (and reference it without prefix $).
Writing Custom Plugins
This is the default plugin sequence:
[lambdaPlugin(), envPlugin(), evaluatePlugin(), varsPlugin()];If you want to add or modify on the plugin level, just modify the above plugins line (the order matters).
A stub for writing a custom plugin:
export const myPlugin = () => ({
staticEnv: {
/* ... */
},
onEnter: ({
state,
envHas,
getFromEnv,
originalNode,
processNode,
processNodeAsync,
current,
}) => {
/* ... */
},
onLeave: ({
state,
envHas,
getFromEnv,
originalNode,
processNode,
processNodeAsync,
node,
current,
}) => {
/* ... */
},
});Both onEnter and onLeave functions should return undefined or one of 3 events:
{ type: 'ERROR', error: Error('MSG') }{ type: 'REPLACE', current: X }{ type: 'PROTECT', current: X }
Recommended to use
onLeaveoveronEnterin most cases.
Thanks to / Inspired by
I have used miniMAL (extended a bit) as a DSL for a couple of years, and all my experience around that went into making Ploson.
Change Log
- 3.1
- Shorthand lambda function support
- Building multiple bundle formats
- 3.0
- Lambda plugin providing syntax to create functions.
- Remade error handling.
- Now adds a
plosonStackproperty instead of adding recursively to the message.
- Now adds a
- Removed
lastArgalias. - Added
BooleantodefaultEnv.
Licence
Hippocratic License Version 2.1