Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Plugin system #142

Merged
merged 8 commits into from
Jun 5, 2021
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,67 @@ Jsep.addIdentifierChar("@");
Jsep.removeIdentifierChar('@');
```

### Plugins
JSEP supports defining custom hooks for extending or modifying the expression parsing.
All hooks are bound to the jsep instance and called with a single argument and return void.
The 'this' context provide access to the internal parsing methods of jsep
to allow reuse as needed. Some hook types will pass an object that allows reading/writing
the `node` property as needed.

#### Hook 'this' context
```typescript
export interface HookScope {
index: number;
expr: string;
char: string; // current character of the expression
code: number; // current character code of the expression
gobbleSpaces: () => void;
gobbleExpressions: (number?) => Eexpression[];
gobbleExpression: () => Expression;
gobbleBinaryOp: () => PossibleExpression;
gobbleBinaryExpression: () => PossibleExpression;
gobbleToken: () => PossibleExpression;
gobbleNumericLiteral: () => PossibleExpression;
gobbleStringLiteral: () => PossibleExpression;
gobbleIdentifier: () => PossibleExpression;
gobbleArguments: (number) => PossibleExpression;
gobbleGroup: () => Expression;
gobbleArray: () => PossibleExpression;
throwError: (string) => void;
}
```

#### Hook Types
* `before-all`: called just before starting all expression parsing.
* `after-all`: called after parsing all. Read/Write `arg.node` as required.
* `gobble-expression`: called just before attempting to parse an expression. Set `arg.node` as required.
* `after-expression`: called just after parsing an expression. Read/Write `arg.node` as required.
* `gobble-token`: called just before attempting to parse a token. Set `arg.node` as required.
* `after-token`: called just after parsing a token. Read/Write `arg.node` as required.
* `gobble-spaces`: called when gobbling spaces.

#### How to add plugins:
```javascript
import { Jsep } from 'jsep';
import 'jsep/plugins/ternary';
```

#### Optional Plugins:
* `ternary`: Built-in by default, adds support for ternary `a ? b : c` expressions

#### Writing Your Own Plugin:
Refer to the `jsep/plugins` folder for examples. In general, the file should look something like:
```javascript
import { Jsep } from '../../jsep.js';
Jsep.hooks.add(/* refer to [Hook Types] */, function myPlugin(env) {
if (this.char === '#') {
env.node = {
/* add a node type */
};
}
});
```

### License

jsep is under the MIT license. See LICENSE file.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,6 @@
"build:watch": "npx rollup -c --watch",
"test": "npx http-server -p 49649 --silent & npx node-qunit-puppeteer http://localhost:49649/test/unit_tests.html",
"docco": "npx docco src/jsep.js --css=src/docco.css --output=annotated_source/",
"lint": "npx eslint src/**/*.js test/*.js"
"lint": "npx eslint src/**/*.js test/*.js test/plugins/**/*.js"
}
}
4 changes: 3 additions & 1 deletion rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ function bundle(type) {
}

export default {
input: "src/jsep.js",
input: [
"src/index.js",
],
output: [
bundle("esm"),
bundle("esm.min"),
Expand Down
53 changes: 53 additions & 0 deletions src/hooks.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
export default class Hooks {
/**
* @callback HookCallback
* @this {*|Jsep} this
* @param {Jsep} env
* @returns: void
*/
/**
* Adds the given callback to the list of callbacks for the given hook.
*
* The callback will be invoked when the hook it is registered for is run.
*
* One callback function can be registered to multiple hooks and the same hook multiple times.
*
* @param {string|object} name The name of the hook, or an object of callbacks keyed by name
* @param {HookCallback|boolean} callback The callback function which is given environment variables.
* @param {?boolean} [first=false] Will add the hook to the top of the list (defaults to the bottom)
* @public
*/
add(name, callback, first) {
if (typeof arguments[0] != 'string') {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpick: spaces for indentation in this entire file (I assume it's the default setting in your editor, so it happened when you copy/pasted).

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So sorry! I added .editorconfig and added the tab as a rule to eslint so it should be fixed :-)

// Multiple hook callbacks, keyed by name
for (let name in arguments[0]) {
this.add(name, arguments[0][name], arguments[1]);
}
}
else {
(Array.isArray(name) ? name : [name]).forEach(function(name) {
this[name] = this[name] || [];

if (callback) {
this[name][first ? 'unshift' : 'push'](callback);
}
}, this);
}
}

/**
* Runs a hook invoking all registered callbacks with the given environment variables.
*
* Callbacks will be invoked synchronously and in the order in which they were registered.
*
* @param {string} name The name of the hook.
* @param {Object<string, any>} env The environment variables of the hook passed to all callbacks registered.
* @public
*/
run(name, env) {
this[name] = this[name] || [];
this[name].forEach(function (callback) {
callback.call(env && env.context ? env.context : env, env);
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

with JSEP, we kind of need access to the Jsep instance, so I think it should always be bound to this from the caller? I went back and forth on how to pass in local information, but since the plugins need to be able to manipulate the results - or provide new results, without returning a value, I still ended up with this.node and this.nodes being passed in and then read back out. It allows reassigning a value, or even clearing a parsed tree. Any better ideas?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The usual pattern is that the hook creation code just passes this instead of a custom env object.
Are there any local variables we want to be passing that are not part of the state on this?

Copy link
Collaborator

@LeaVerou LeaVerou May 4, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I see. Yeah, in that case the typical pattern is that you pass in {context: this, node, nodes} as the env. I don't think it's a good idea to have instance properties that are essentially local variables that don't make sense outside the execution context that created them. Are these properties more generally useful?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the suggestion! I'll push a change that uses env.context and env.node. See if it's more what you had in mind?

});
}
}
6 changes: 6 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// Add default plugins:
import jsep from './jsep.js';
import './plugins/ternary/ternary.js';

export * from './jsep.js';
export default jsep;
98 changes: 45 additions & 53 deletions src/jsep.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// JavaScript Expression Parser (JSEP) <%= version %>
// JSEP may be freely distributed under the MIT License
// https://ericsmekens.github.io/jsep/
import Hooks from './hooks.js';

export class Jsep {
/**
* @returns {string}
Expand Down Expand Up @@ -249,25 +251,30 @@ export class Jsep {
|| ch === Jsep.CR_CODE) {
ch = this.expr.charCodeAt(++this.index);
}
Jsep.hooks.run('gobble-spaces', this);
}

/**
* Top-level method to parse all expressions and returns compound or single node
* @returns {jsep.Expression}
*/
parse() {
Jsep.hooks.run('before-all', this);
const nodes = this.gobbleExpressions();

// If there's only one expression just try returning the expression
const env = { context: this };
if (nodes.length === 1) {
return nodes[0];
env.node = nodes[0];
}
else {
return {
env.node = {
type: Jsep.COMPOUND,
body: nodes
};
}
Jsep.hooks.run('after-all', env);
return env.node;
}

/**
Expand Down Expand Up @@ -305,48 +312,20 @@ export class Jsep {
return nodes;
}

//
/**
* The main parsing function. Much of this code is dedicated to ternary expressions
* The main parsing function.
* @returns {?jsep.Expression}
*/
gobbleExpression() {
const test = this.gobbleBinaryExpression();

const env = { context: this };
Jsep.hooks.run('gobble-expression', env);
if (!env.node) {
env.node = this.gobbleBinaryExpression();
}
this.gobbleSpaces();

if (this.code === Jsep.QUMARK_CODE) {
// Ternary expression: test ? consequent : alternate
this.index++;
const consequent = this.gobbleExpression();

if (!consequent) {
this.throwError('Expected expression');
}

this.gobbleSpaces();

if (this.code === Jsep.COLON_CODE) {
this.index++;
const alternate = this.gobbleExpression();

if (!alternate) {
this.throwError('Expected expression');
}
return {
type: Jsep.CONDITIONAL_EXP,
test,
consequent,
alternate
};
}
else {
this.throwError('Expected :');
}
}
else {
return test;
}
Jsep.hooks.run('after-expression', env);
return env.node;
}

/**
Expand Down Expand Up @@ -465,9 +444,16 @@ export class Jsep {
* @returns {boolean|jsep.Expression}
*/
gobbleToken() {
let ch, to_check, tc_len, node;
let ch, to_check, tc_len;

this.gobbleSpaces();
const env = { context: this };
Jsep.hooks.run('gobble-token', env);
if (env.node) {
Jsep.hooks.run('after-token', env);
return env.node;
}

ch = this.code;

if (Jsep.isDecimalDigit(ch) || ch === Jsep.PERIOD_CODE) {
Expand All @@ -477,10 +463,10 @@ export class Jsep {

if (ch === Jsep.SQUOTE_CODE || ch === Jsep.DQUOTE_CODE) {
// Single or double quotes
node = this.gobbleStringLiteral();
env.node = this.gobbleStringLiteral();
}
else if (ch === Jsep.OBRACK_CODE) {
node = this.gobbleArray();
env.node = this.gobbleArray();
}
else {
to_check = this.expr.substr(this.index, Jsep.max_unop_len);
Expand All @@ -495,27 +481,30 @@ export class Jsep {
(this.index + to_check.length < this.expr.length && !Jsep.isIdentifierPart(this.expr.charCodeAt(this.index + to_check.length)))
)) {
this.index += tc_len;
return {
env.node = {
type: Jsep.UNARY_EXP,
operator: to_check,
argument: this.gobbleToken(),
prefix: true
};
Jsep.hooks.run('after-token', env);
return env.node;
}

to_check = to_check.substr(0, --tc_len);
}

if (Jsep.isIdentifierStart(ch)) {
node = this.gobbleIdentifier();
env.node = this.gobbleIdentifier();
}
else if (ch === Jsep.OPAREN_CODE) { // open parenthesis
node = this.gobbleGroup();
env.node = this.gobbleGroup();
}
}

if (!node) {
return false;
if (!env.node) {
Jsep.hooks.run('after-token', env);
return env.node;
}

this.gobbleSpaces();
Expand All @@ -532,18 +521,18 @@ export class Jsep {

if (ch === Jsep.PERIOD_CODE) {
this.gobbleSpaces();
node = {
env.node = {
type: Jsep.MEMBER_EXP,
computed: false,
object: node,
object: env.node,
property: this.gobbleIdentifier()
};
}
else if (ch === Jsep.OBRACK_CODE) {
node = {
env.node = {
type: Jsep.MEMBER_EXP,
computed: true,
object: node,
object: env.node,
property: this.gobbleExpression()
};
this.gobbleSpaces();
Expand All @@ -555,17 +544,18 @@ export class Jsep {
}
else if (ch === Jsep.OPAREN_CODE) {
// A function call is being made; gobble all the arguments
node = {
env.node = {
type: Jsep.CALL_EXP,
'arguments': this.gobbleArguments(Jsep.CPAREN_CODE),
callee: node
callee: env.node
};
}
this.gobbleSpaces();
ch = this.code;
}

return node;
Jsep.hooks.run('after-token', env);
return env.node;
}

/**
Expand Down Expand Up @@ -827,6 +817,7 @@ export class Jsep {
};
}
}
Jsep.hooks = new Hooks();

// Backward Compatibility (before adding the static fields):
const jsep = expr => (new Jsep(expr)).parse();
Expand All @@ -836,6 +827,7 @@ staticMethods
.forEach((m) => {
jsep[m] = Jsep[m];
});
jsep.hooks = Jsep.hooks;
export default jsep;


Expand Down
Loading