-
Notifications
You must be signed in to change notification settings - Fork 153
feat: Binary runtime helper. #255
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
Merged
Merged
Changes from 1 commit
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
cff9859
feat: Binary runtime POC.
amiller-gh 52673b8
feat: Nested expressions in binary runtime.
amiller-gh 73d4932
chore: Comments and code for minification.
amiller-gh cf8c472
chore: Code cleanup.
amiller-gh 5222e44
feat: Improve binary opcode packing and runtime size.
amiller-gh 357ebdb
chore: Avoid magic numbers.
chriseppstein c99f39d
chore: Faster unique expression ids.
chriseppstein 75ba058
chore: Simpler typeguard for not expressions.
chriseppstein 73df72f
chore: Use jsdoc comments.
chriseppstein a876d45
chore: Fix lint error.
chriseppstein File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,102 @@ | ||
export type whatever = string | number | boolean | symbol | object | null | undefined | void; | ||
|
||
export const enum OP_CODE { | ||
OPEN = "000", | ||
VAL = "001", | ||
NOT = "010", | ||
OR = "011", | ||
AND = "100", | ||
EQUAL = "101", | ||
CLOSE = "110", | ||
CONCAT = "111", | ||
} | ||
|
||
// The maximum safe number of opcodes we're able to encode in a single base 36 string. | ||
// The maximum number of binary digits we're allowed in a number in Javascript is 53: | ||
// Number.MAX_SAFE_INTEGER.toString(2).length === 53. | ||
// However! When converting from base36 and back again, leading zeros are stripped. To | ||
// fix this, we prefix every shape with a throwaway bit at the beginning, so the actual | ||
// safe number is 52. | ||
const MAX_SAFE_OPCODES = 52; | ||
|
||
export class Expression { | ||
amiller-gh marked this conversation as resolved.
Show resolved
Hide resolved
|
||
public ops: string[] = []; | ||
private classes: string[] = []; | ||
private exprs: whatever[] = []; | ||
private exprIdx: Map<whatever, number> = new Map(); | ||
|
||
// The bit size of value encodings is dependent on the number of value | ||
// indices we need to keep track of. | ||
private valSize() { return ~~Math.log2(this.exprs.length - 1) + 1; } | ||
amiller-gh marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
// Track a value, referenced by index. | ||
val(expr: whatever) { | ||
this.ops.push(OP_CODE.VAL); | ||
let idx = this.exprIdx.get(expr); | ||
if (idx === undefined) { | ||
idx = this.exprs.length; | ||
this.exprIdx.set(expr, idx); | ||
this.exprs.push(expr); | ||
} | ||
this.ops.push(idx.toString(2)); | ||
return this; | ||
} | ||
amiller-gh marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
// `when()` is a proxy for `val()` for better english-language-like constructor chains. | ||
when(expr: whatever) { return this.val(expr); } | ||
|
||
// Track an inverse value. | ||
not(expr: whatever) { | ||
this.ops.push(OP_CODE.NOT); | ||
return this.val(expr); | ||
} | ||
|
||
// The start of an expression. Apply this class when the following | ||
// expression evaluates to true for a given input. | ||
apply(klass: string) { | ||
this.classes.push(klass); | ||
if (this.ops.length) { this.ops.push(OP_CODE.CONCAT); } | ||
return this; | ||
} | ||
|
||
// Operands | ||
get open() { this.ops.push(OP_CODE.OPEN); return this; } | ||
get or() { this.ops.push(OP_CODE.OR); return this; } | ||
get and() { this.ops.push(OP_CODE.AND); return this; } | ||
get equals() { this.ops.push(OP_CODE.EQUAL); return this; } | ||
get close() { this.ops.push(OP_CODE.CLOSE); return this; } | ||
|
||
// Introspection methods. | ||
getExprs(): whatever[] { return this.exprs.slice(0); } | ||
getClasses(): string[] { return this.classes.slice(0); } | ||
getOps(): string[] { return this.ops.slice(); } | ||
|
||
// Calculate the binary string encoding of this expression's logic shape. | ||
getShape(): string[] { | ||
amiller-gh marked this conversation as resolved.
Show resolved
Hide resolved
|
||
const OUT = []; | ||
amiller-gh marked this conversation as resolved.
Show resolved
Hide resolved
|
||
let working = ""; | ||
amiller-gh marked this conversation as resolved.
Show resolved
Hide resolved
|
||
let isVal = false; | ||
for (let op of this.ops) { | ||
|
||
// If this opcode is a VAL (preceded by VAL opcode) ensure its binary | ||
// value is padded to be the valSize length. | ||
if (isVal) { | ||
op = "0".repeat(Math.max(this.valSize() - op.length , 0)) + op; | ||
isVal = false; | ||
} | ||
|
||
// If this opcode is a VAL, consume the next op as a value. | ||
isVal = op === OP_CODE.VAL; | ||
|
||
// If this opcode would overflow the MAX_SAVE_INTEGER when converted to | ||
// an integer, strike a new base36 string. | ||
if (working.length + op.length >= MAX_SAFE_OPCODES) { | ||
OUT.push(parseInt(`1${working.split("").reverse().join("")}`, 2).toString(36)); | ||
amiller-gh marked this conversation as resolved.
Show resolved
Hide resolved
|
||
working = ""; | ||
} | ||
working += op; | ||
} | ||
if (working) { OUT.push(parseInt(`1${working.split("").reverse().join("")}`, 2).toString(36)); } | ||
return OUT; | ||
} | ||
amiller-gh marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
amiller-gh marked this conversation as resolved.
Show resolved
Hide resolved
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,96 @@ | ||
export type whatever = string | number | boolean | symbol | object | null | undefined | void; | ||
|
||
export function runtime(shape: string[], classes: string[], exprs: whatever[]): string { | ||
|
||
// We dynamically determine the variable window size based on the number of | ||
// expressions it is possible to reference. It is the compiler's responsibility | ||
// to guarantee the expression shape matches at build time. | ||
const VAR_SIZE = ~~Math.log2(exprs.length - 1) + 1; | ||
|
||
let out = ""; // Output class list. | ||
let klass = 0; // Current class we are determining presence for. | ||
let val = null; // Working boolean expression value. | ||
let step = 0; // Character count used for opcode discovery. | ||
let current = null; // Current discovered opcode to evaluate. | ||
let next = 0; // This is a single lookahead parser – next opcode will be stored here. | ||
let op = null; // Operation to evaluate on next val discovered. | ||
let invert = false; // Should we invert the next discovered value. | ||
// let stack = []; // Stack for nested boolean expressions | ||
|
||
// For each 32 bit integer passed to us as a base 36 string | ||
for ( let segment of shape ) { | ||
|
||
// Convert binary string segment to a base 10 integer | ||
let integer = parseInt(segment, 36); | ||
|
||
// Process each bit in this 32 bit integer. | ||
// Note: `while` loop is faster than a `for` loop here. | ||
let iters = 32; | ||
while (iters--) { | ||
|
||
// Construct our lookahead opcode and "pop" a bit off the end | ||
// of our integer's binary representation. | ||
let size = (current === 1 ? VAR_SIZE : 3); | ||
next += integer % 2 * (2 * (size - 1 - step) || 1); // tslint:disable-line | ||
integer = integer >>> 1; // tslint:disable-line | ||
amiller-gh marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
// When we have discovered the next opcode, process. | ||
if (!(step = ++step % size)) { | ||
|
||
// Each opcode type requires implementation | ||
switch (current) { | ||
|
||
// If no current op-code, move on. | ||
case null: break; | ||
|
||
// OPEN: `000` | ||
case 0: break; | ||
|
||
// VAL: `001` | ||
case 1: | ||
let tmp = invert ? !exprs[next] : !!exprs[next]; | ||
switch (op) { | ||
case 3: val = val || tmp; break; | ||
case 4: val = val && tmp; break; | ||
case 5: val = val === tmp; break; | ||
default: val = tmp; | ||
} | ||
op = null; | ||
invert = false; | ||
break; | ||
|
||
// NOT: `010` | ||
case 2: invert = true; break; | ||
amiller-gh marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
// OR: `011` | ||
case 3: op = 3; break; | ||
|
||
// AND: `100` | ||
case 4: op = 4; break; | ||
|
||
// EQUAL: `101` | ||
case 5: op = 5; break; | ||
|
||
// CLOSE: `110` | ||
case 6: break; | ||
|
||
// CONCAT: `111` | ||
case 7: | ||
out += val ? (out ? " " : "") + classes[klass] : ""; | ||
klass++; | ||
break; | ||
|
||
// If op-code is unrecognized, throw. | ||
default: throw new Error("Unknown CSS Blocks op-code."); | ||
amiller-gh marked this conversation as resolved.
Show resolved
Hide resolved
|
||
} | ||
|
||
// Begin construction of next opcode. Skip `val` indices. | ||
current = (current === 1) ? null : next; | ||
next = 0; | ||
} | ||
} | ||
out += val ? (out ? " " : "") + classes[klass] : ""; | ||
} | ||
|
||
return out; | ||
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,9 +1,39 @@ | ||
import { assert } from "chai"; | ||
import { suite, test } from "mocha-typescript"; | ||
|
||
import { Expression, OP_CODE } from "../src/constructor"; | ||
import { runtime } from "../src/runtime"; | ||
|
||
@suite("Expression") | ||
export class ExpressionTests { | ||
@test "runs"() { | ||
assert.equal(1, 1); | ||
|
||
@test "simple equality expression"() { | ||
let expr = new Expression(); | ||
expr.apply("fubar").when(true).equals.val(false); | ||
assert.deepEqual(expr.getClasses(), ["fubar"]); | ||
assert.deepEqual(expr.getExprs(), [true, false]); | ||
assert.deepEqual(expr.getOps(), [OP_CODE.VAL, "0", OP_CODE.EQUAL, OP_CODE.VAL, "1"]); | ||
assert.deepEqual([parseInt(`1${expr.getOps().join("").split("").reverse().join("")}`, 2).toString(36)], expr.getShape()); | ||
assert.equal(runtime(expr.getShape(), expr.getClasses(), [true, false]), ""); | ||
assert.equal(runtime(expr.getShape(), expr.getClasses(), [true, true]), "fubar"); | ||
} | ||
|
||
@test "simple not expression"() { | ||
let expr = new Expression(); | ||
expr.apply("fubar").when(true).equals.not(false); | ||
assert.deepEqual(expr.getClasses(), ["fubar"]); | ||
assert.deepEqual(expr.getExprs(), [true, false]); | ||
assert.deepEqual(expr.getOps(), [OP_CODE.VAL, "0", OP_CODE.EQUAL, OP_CODE.NOT, OP_CODE.VAL, "1"]); | ||
amiller-gh marked this conversation as resolved.
Show resolved
Hide resolved
|
||
assert.equal(runtime(expr.getShape(), expr.getClasses(), [true, false]), "fubar"); | ||
assert.equal(runtime(expr.getShape(), expr.getClasses(), [true, true]), ""); | ||
} | ||
|
||
@test "multiple classes"() { | ||
let expr = new Expression(); | ||
expr.apply("fubar").when("arg0").equals.not("arg1"); | ||
expr.apply("bizbaz").when("arg0").equals.val("arg2"); | ||
assert.equal(runtime(expr.getShape(), expr.getClasses(), [true, false, false]), "fubar"); | ||
assert.equal(runtime(expr.getShape(), expr.getClasses(), [true, false, true]), "fubar bizbaz"); | ||
assert.equal(runtime(expr.getShape(), expr.getClasses(), [false, false, true]), ""); | ||
} | ||
} |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.