diff --git a/package.json b/package.json index 85efe2f..ac27b35 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,8 @@ "tslib": "2" }, "dependencies": { - "@jsonjoy.com/buffers": "^1.0.0" + "@jsonjoy.com/buffers": "^1.0.0", + "@jsonjoy.com/codegen": "^1.0.0" }, "devDependencies": { "@types/benchmark": "^2.1.2", diff --git a/src/codegen/Codegen.ts b/src/codegen/Codegen.ts deleted file mode 100644 index 759d568..0000000 --- a/src/codegen/Codegen.ts +++ /dev/null @@ -1,308 +0,0 @@ -import {compileClosure} from '.'; -import type {JavaScriptLinked} from './types'; - -/** - * Inline JavaScript statements that are executed in main function body. - */ -export class CodegenStepExecJs { - constructor(public readonly js: string) {} -} - -/** - * A step can be `CodegenStepExecJs` or some application specific step, which - * will later will need to be converted to `CodegenStepExecJs`. - */ -type JsonSerializerStep = CodegenStepExecJs | unknown; - -/** - * Configuration options for {@link Codegen} instances. - */ -export interface CodegenOptions> { - /** - * Inline JavaScript string that represents the arguments that will be passed - * to the main function body. Defaults to "r0", i.e. the first register. - */ - args?: string[]; - - /** - * Name of the generated function. - */ - name?: string; - - /** - * Inline JavaScript statements, that execute at the beginning of the main - * function body. - */ - prologue?: string; - - /** - * Inline JavaScript statements, that execute at the end of the main - * function body. - */ - epilogue?: string | (() => string); - - /** - * Converts all steps to `CodegenStepExecJs`. - */ - processSteps?: (steps: JsonSerializerStep[]) => CodegenStepExecJs[]; - - /** - * Predefined list of dependencies that can be linked on demand. Dependency is - * linked with the name of the property and is linked only once. - */ - linkable?: Linkable; -} - -export type CodegenGenerateOptions = Pick; - -/** - * A helper class which helps with building JavaScript code for a single - * function. It keeps track of external dependencies, internally generated - * constants, and execution steps, which at the end are all converted to - * to an executable JavaScript function. - * - * The final output is a JavaScript function enclosed in a closure: - * - * ```js - * (function(d1, d2, d3) { - * var c1 = something; - * var c2 = something; - * var c3 = something; - * return function(r0) { - * var r1 = something; - * var r2 = something; - * var r3 = something; - * return something; - * } - * }) - * ``` - * - * Where `d*` are the external dependencies, `c*` are the internal constants, - * and `r*` are the local immutable infinite registers. - */ -export class Codegen< - Fn extends (...deps: any[]) => any = (...deps: unknown[]) => unknown, - Linkable = Record, -> { - /** @ignore */ - protected steps: JsonSerializerStep[] = []; - - /** @ignore */ - public options: Required>; - - constructor(opts: CodegenOptions) { - this.options = { - args: ['r0'], - name: '', - prologue: '', - epilogue: '', - processSteps: (steps) => steps.filter((step) => step instanceof CodegenStepExecJs) as CodegenStepExecJs[], - linkable: {} as Linkable, - ...opts, - }; - this.registerCounter = this.options.args.length; - } - - /** - * Add one or more JavaScript statements to the main function body. - */ - public js(js: string): void { - this.steps.push(new CodegenStepExecJs(js)); - } - - public var(expression?: string): string { - const r = this.getRegister(); - if (expression) this.js('var ' + r + ' = ' + expression + ';'); - else this.js('var ' + r + ';'); - return r; - } - - public if(condition: string, then: () => void, otherwise?: () => void): void { - this.js('if (' + condition + ') {'); - then(); - if (otherwise) { - this.js('} else {'); - otherwise(); - } - this.js('}'); - } - - public while(condition: string, block: () => void): void { - this.js('while (' + condition + ') {'); - block(); - this.js('}'); - } - - public doWhile(block: () => void, condition: string): void { - this.js('do {'); - block(); - this.js('} while (' + condition + ');'); - } - - public switch( - expression: string, - cases: [match: string | number | boolean | null, block: () => void, noBreak?: boolean][], - def?: () => void, - ): void { - this.js('switch (' + expression + ') {'); - for (const [match, block, noBreak] of cases) { - this.js('case ' + match + ': {'); - block(); - if (!noBreak) this.js('break;'); - this.js('}'); - } - if (def) { - this.js('default: {'); - def(); - this.js('}'); - } - this.js('}'); - } - - public return(expression: string): void { - this.js('return ' + expression + ';'); - } - - /** - * Add any application specific execution step. Steps of `unknown` type - * later need to converted to `CodegenStepExecJs` steps in the `.processStep` - * callback. - * - * @param step A step in function execution logic. - */ - public step(step: unknown): void { - this.steps.push(step); - } - - protected registerCounter: number; - - /** - * Codegen uses the idea of infinite registers. It starts with `0` and - * increments it by one for each new register. Best practice is to use - * a new register for each new variable and keep them immutable. - * - * Usage: - * - * ```js - * const r = codegen.getRegister(); - * codegen.js(`const ${r} = 1;`); - * ``` - * - * @returns a unique identifier for a variable. - */ - public getRegister(): string { - return `r${this.registerCounter++}`; - } - public r(): string { - return this.getRegister(); - } - - /** @ignore */ - protected dependencies: unknown[] = []; - protected dependencyNames: string[] = []; - - /** - * Allows to wire up dependencies to the generated code. - * - * @param dep Any JavaScript dependency, could be a function, an object, - * or anything else. - * @param name Optional name of the dependency. If not provided, a unique - * name will be generated, which starts with `d` and a counter - * appended. - * @returns Returns the dependency name, a code symbol which can be used as - * variable name. - */ - public linkDependency(dep: unknown, name: string = 'd' + this.dependencies.length): string { - this.dependencies.push(dep); - this.dependencyNames.push(name); - return name; - } - - /** - * Sames as {@link Codegen#linkDependency}, but allows to wire up multiple - * dependencies at once. - */ - public linkDependencies(deps: unknown[]): string[] { - return deps.map((dep) => this.linkDependency(dep)); - } - - protected linked: {[key: string]: 1} = {}; - - /** - * Link a dependency from the pre-defined `options.linkable` object. This method - * can be called many times with the same dependency name, the dependency will - * be linked only once. - * - * @param name Linkable dependency name. - */ - public link(name: keyof Linkable): void { - if (this.linked[name as string]) return; - this.linked[name as string] = 1; - this.linkDependency(this.options.linkable[name], name as string); - } - - /** @ignore */ - protected constants: string[] = []; - protected constantNames: string[] = []; - - /** - * Allows to encode any code or value in the closure of the generated - * function. - * - * @param constant Any JavaScript value in string form. - * @param name Optional name of the constant. If not provided, a unique - * name will be generated, which starts with `c` and a counter - * appended. - * @returns Returns the constant name, a code symbol which can be used as - * variable name. - */ - public addConstant(constant: string, name: string = 'c' + this.constants.length): string { - this.constants.push(constant); - this.constantNames.push(name); - return name; - } - - /** - * Sames as {@link Codegen#addConstant}, but allows to create multiple - * constants at once. - */ - public addConstants(constants: string[]): string[] { - return constants.map((constant) => this.addConstant(constant)); - } - - /** - * Returns generated JavaScript code with the dependency list. - * - * ```js - * const code = codegen.generate(); - * const fn = eval(code.js)(...code.deps); - * const result = fn(...args); - * ``` - */ - public generate(opts: CodegenGenerateOptions = {}): JavaScriptLinked { - const {name, args, prologue, epilogue} = {...this.options, ...opts}; - const steps = this.options.processSteps(this.steps); - const js = `(function(${this.dependencyNames.join(', ')}) { -${this.constants.map((constant, index) => `var ${this.constantNames[index]} = (${constant});`).join('\n')} -return ${name ? `function ${name}` : 'function'}(${args.join(',')}){ -${prologue} -${steps.map((step) => (step as CodegenStepExecJs).js).join('\n')} -${typeof epilogue === 'function' ? epilogue() : epilogue || ''} -}})`; - // console.log(js); - return { - deps: this.dependencies, - js: js as JavaScriptLinked['js'], - }; - } - - /** - * Compiles the generated JavaScript code into a function. - * - * @returns JavaScript function ready for execution. - */ - public compile(opts?: CodegenGenerateOptions): Fn { - const closure = this.generate(opts); - return compileClosure(closure); - } -} diff --git a/src/codegen/README.md b/src/codegen/README.md deleted file mode 100644 index 5a3951e..0000000 --- a/src/codegen/README.md +++ /dev/null @@ -1,17 +0,0 @@ -# util/codegen - -This folder contains utilities for generating code. It is sometimes possible to -generate an optimized function that will execute significantly faster given -a "schema", or "template", of execution. - -Some examples: - -- Deep equality comparison function: if we know one object in advance we can - generate an optimized function which accepts a single object. It is - implemented in `json-equal` library. -- JSON Patch execution: if we know the JSON Patch in advance, we can generate - an optimized function which applies the JSON patch in the most efficient way. - It is implemented in `json-patch` library. -- Given a `json-type` schema of a JSON object, it is possible to generate - optimized functions for validation and serialization of objects according to - that schema. diff --git a/src/codegen/__tests__/Codegen.spec.ts b/src/codegen/__tests__/Codegen.spec.ts deleted file mode 100644 index 4183da6..0000000 --- a/src/codegen/__tests__/Codegen.spec.ts +++ /dev/null @@ -1,31 +0,0 @@ -import {CodegenStepExecJs} from '..'; -import {Codegen} from '../Codegen'; - -test('can generate a simple function', () => { - const codegen = new Codegen({ - name: 'foobar', - args: ['a', 'b'], - prologue: 'var res = 0;', - epilogue: 'return res;', - processSteps: (steps) => { - return steps.map((step) => { - if (typeof step === 'number') { - return new CodegenStepExecJs(`a += ${step};`); - } else return step; - }) as CodegenStepExecJs[]; - }, - }); - codegen.step(4); - const [c1, c2] = codegen.addConstants(['1', '2']); - codegen.js(`b += ${c1} + ${c2};`); - const byTwo = (num: number) => 2 * num; - codegen.linkDependency(byTwo, 'byTwo'); - codegen.js(`res += byTwo(a) + byTwo(b);`); - const code = codegen.generate(); - const fn = codegen.compile(); - // console.log(code.js); - expect(code.deps).toStrictEqual([byTwo]); - expect(typeof code.js).toBe('string'); - expect(fn(1, 2)).toBe(20); - expect(fn.name).toBe('foobar'); -}); diff --git a/src/codegen/compile.ts b/src/codegen/compile.ts deleted file mode 100644 index a1c0bbb..0000000 --- a/src/codegen/compile.ts +++ /dev/null @@ -1,7 +0,0 @@ -import {JavaScriptLinked} from '.'; -import {JavaScript} from './types'; - -// tslint:disable-next-line -export const compile = (js: JavaScript): T => eval(js); - -export const compileClosure = (fn: JavaScriptLinked): T => compile(fn.js)(...fn.deps); diff --git a/src/codegen/dynamicFunction.ts b/src/codegen/dynamicFunction.ts deleted file mode 100644 index 38a622a..0000000 --- a/src/codegen/dynamicFunction.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Wraps a function into a proxy function with the same signature, but which can - * be re-implemented by the user at runtime. - * - * @param implementation Initial implementation. - * @returns Proxy function and implementation setter. - */ -export const dynamicFunction = any>( - implementation: F, -): [fn: F, set: (fn: F) => void] => { - const proxy = ((...args) => implementation(...args)) as F; - const set = (f: F) => { - implementation = f; - }; - return [proxy, set]; -}; diff --git a/src/codegen/index.ts b/src/codegen/index.ts index 3a96aef..fa927d2 100644 --- a/src/codegen/index.ts +++ b/src/codegen/index.ts @@ -1,3 +1 @@ -export * from './types'; -export * from './compile'; -export * from './Codegen'; +export * from '@jsonjoy.com/codegen'; diff --git a/src/codegen/switch.ts b/src/codegen/switch.ts deleted file mode 100644 index 3c98a12..0000000 --- a/src/codegen/switch.ts +++ /dev/null @@ -1,15 +0,0 @@ -import {dynamicFunction} from './dynamicFunction'; - -/** - * Switcher for code generation. It first executes "evaluation" function - * 3 times, and then generates optimized code. - */ -export const createSwitch = any>(fn: F, codegen: () => F): F => { - let counter = 0; - const [proxy, set] = dynamicFunction((...args) => { - if (counter > 2) set(codegen()); - counter++; - return fn(...args); - }); - return proxy as F; -}; diff --git a/src/codegen/types.ts b/src/codegen/types.ts deleted file mode 100644 index b9bcd8a..0000000 --- a/src/codegen/types.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type {Brand} from '../types'; - -/** - * Represents a string which contains JavaScript code, which can be - * executed by the `eval` function. - * - * ```ts - * const code: JavaScript<() => {}> = `() => {}`; - * const fn = eval(code); // () => {} - * ``` - */ -export type JavaScript = Brand; - -/** - * Represents a string which contains JavaScript code, which is enclosed - * in a JavaScript closure function. The dependencies can be "linked" to - * the JavaScript code, by executing the outer closure function with the - * list of dependencies as arguments. - * - * ```ts - * const multBy: JavaScriptClosure<(x: number) => number, [by: number]> = - * 'function(by) { return function (x) { return x * by }}'; - * - * const multBy3 = eval(multBy)(3); - * - * multBy3(5); // 15 - * ``` - */ -export type JavaScriptClosure = JavaScript<(...deps: D) => Js>; - -/** - * Represents a {@link JavaScriptClosure} with a fixed list of dependencies, - * that can be linked to the JavaScript code-generated closure. - */ -export interface JavaScriptLinked { - deps: Dependencies; - js: JavaScriptClosure; -} diff --git a/src/codegen/util/JsExpression.ts b/src/codegen/util/JsExpression.ts deleted file mode 100644 index 7ddbd4e..0000000 --- a/src/codegen/util/JsExpression.ts +++ /dev/null @@ -1,41 +0,0 @@ -/** - * JsExpression monad allows to write JS expression as strings which depend on each - * other and tracks whether an expression was used or not. - * - * ```ts - * const expr = new JsExpression(() => 'r0'); - * const subExpr = expr.chain((expr) => `${expr}["key"]`); - * - * expr.wasUsed; // false - * subExpr.use(); // r0["key"] - * expr.wasUsed; // true - * subExpr.wasUsed; // true - * ``` - */ -export class JsExpression { - private _wasUsed: boolean = false; - private _expression?: string; - private _listeners: ((expr: string) => void)[] = []; - - constructor(private expression: () => string) {} - - public get wasUsed(): boolean { - return this._wasUsed; - } - - public use(): string { - if (this._wasUsed) return this._expression!; - this._wasUsed = true; - const expression = (this._expression = this.expression()); - for (const listener of this._listeners) listener(expression); - return expression; - } - - public chain(use: (expr: string) => string): JsExpression { - return new JsExpression(() => use(this.use())); - } - - public addListener(listener: (expr: string) => void): void { - this._listeners.push(listener); - } -} diff --git a/src/codegen/util/helpers.ts b/src/codegen/util/helpers.ts deleted file mode 100644 index 8558b29..0000000 --- a/src/codegen/util/helpers.ts +++ /dev/null @@ -1,6 +0,0 @@ -export const emitStringMatch = (expression: string, offset: string, match: string) => { - const conditions: string[] = []; - for (let i = 0; i < match.length; i++) - conditions.push(`${match.charCodeAt(i)} === ${expression}.charCodeAt(${offset} + ${i})`); - return conditions.join(' && '); -}; diff --git a/src/codegen/util/normalizeAccessor.ts b/src/codegen/util/normalizeAccessor.ts deleted file mode 100644 index 8bfa283..0000000 --- a/src/codegen/util/normalizeAccessor.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const normalizeAccessor = (accessor: string): string => { - if (/^[a-z_][a-z_0-9]*$/i.test(accessor)) { - return '.' + accessor; - } else { - return `[${JSON.stringify(accessor)}]`; - } -}; diff --git a/src/json-random/RandomJson.ts b/src/json-random/RandomJson.ts index 0d7e365..00f0e6b 100644 --- a/src/json-random/RandomJson.ts +++ b/src/json-random/RandomJson.ts @@ -1,3 +1,5 @@ +import {randomString, Token} from './string'; + type JsonValue = unknown; /** @ignore */ @@ -14,9 +16,10 @@ export interface NodeOdds { } export interface RandomJsonOptions { - rootNode: 'object' | 'array' | undefined; + rootNode: 'object' | 'array' | 'string' | undefined; nodeCount: number; odds: NodeOdds; + strings?: Token; } const defaultOpts: RandomJsonOptions = { @@ -233,15 +236,20 @@ export class RandomJson { this.opts.odds.binary + this.opts.odds.array + this.opts.odds.object; - this.root = - this.opts.rootNode === 'object' - ? {} - : this.opts.rootNode === 'array' - ? [] - : this.pickContainerType() === 'object' - ? {} - : []; - this.containers.push(this.root as ContainerNode); + if (this.opts.rootNode === 'string') { + this.root = this.generateString(); + this.opts.nodeCount = 0; + } else { + this.root = + this.opts.rootNode === 'object' + ? {} + : this.opts.rootNode === 'array' + ? [] + : this.pickContainerType() === 'object' + ? {} + : []; + this.containers.push(this.root as ContainerNode); + } } /** @@ -281,7 +289,7 @@ export class RandomJson { case 'number': return RandomJson.genNumber(); case 'string': - return RandomJson.genString(); + return this.generateString(); case 'binary': return RandomJson.genBinary(); case 'array': @@ -291,6 +299,11 @@ export class RandomJson { } } + protected generateString(): string { + const strings = this.opts.strings; + return strings ? randomString(strings) : RandomJson.genString(); + } + /** @ignore */ public pickNodeType(): NodeType { const odd = Math.random() * this.totalOdds; diff --git a/src/json-random/__tests__/RandomJson.spec.ts b/src/json-random/__tests__/RandomJson.spec.ts index fd13f23..a4a9983 100644 --- a/src/json-random/__tests__/RandomJson.spec.ts +++ b/src/json-random/__tests__/RandomJson.spec.ts @@ -120,3 +120,17 @@ test('random strings can be converted to UTF-8', () => { expect(test).toBe(str); } }); + +test('can specify string generation schema', () => { + const str = RandomJson.generate({ + rootNode: 'string', + strings: [ + 'list', + [ + ['repeat', 2, 2, 'xx'], + ['pick', ['y']], + ], + ], + }); + expect(str).toBe('xxxxy'); +}); diff --git a/src/json-random/__tests__/string.spec.ts b/src/json-random/__tests__/string.spec.ts new file mode 100644 index 0000000..34ae89a --- /dev/null +++ b/src/json-random/__tests__/string.spec.ts @@ -0,0 +1,41 @@ +import {randomString, Token} from '../string'; + +// Tests for randomString +describe('randomString', () => { + it('should pick a random string from the array', () => { + const token: Token = ['pick', ['apple', 'banana', 'cherry']]; + const result = randomString(token); + expect(['apple', 'banana', 'cherry']).toContain(result); + }); + + it('should repeat a pattern a random number of times', () => { + const token: Token = ['repeat', 2, 5, ['pick', ['x', 'y', 'z', ' ']]]; + const result = randomString(token); + expect(result.length).toBeGreaterThanOrEqual(2); + expect(result.length).toBeLessThanOrEqual(5); + }); + + it('should pick a random character from the Unicode range', () => { + const token: Token = ['range', 65, 90]; // A-Z + const result = randomString(token); + expect(result).toMatch(/^[A-Z]$/); + }); + + // test tlist token + it('executes a list of tokens', () => { + const token: Token = [ + 'list', + [ + ['pick', ['monkey', 'dog', 'cat']], + ['pick', [' ']], + ['pick', ['ate', 'threw', 'picked']], + ['pick', [' ']], + ['pick', ['apple', 'banana', 'cherry']], + ], + ]; + const result = randomString(token); + expect(/monkey|dog|cat/.test(result)).toBe(true); + expect(/ate|threw|picked/.test(result)).toBe(true); + expect(/apple|banana|cherry/.test(result)).toBe(true); + }); +}); diff --git a/src/json-random/index.ts b/src/json-random/index.ts index 553584b..8cd775e 100644 --- a/src/json-random/index.ts +++ b/src/json-random/index.ts @@ -1 +1,2 @@ export * from './RandomJson'; +export * from './string'; diff --git a/src/json-random/string.ts b/src/json-random/string.ts new file mode 100644 index 0000000..5c2f2c6 --- /dev/null +++ b/src/json-random/string.ts @@ -0,0 +1,65 @@ +/** + * Tokens used to specify random string generation options + */ +export type Token = TokenLiteral | TokenPick | TokenRepeat | TokenRange | TokenList; + +/** + * A string literal to use as-is. + */ +export type TokenLiteral = string; + +/** + * Picks a random string from the provided array of strings. + * The `from` array can contain any number of strings. + */ +export type TokenPick = [type: 'pick', from: string[]]; + +/** + * Repeats `pattern` a random number of times between `min` and `max`. + */ +export type TokenRepeat = [type: 'repeat', min: number, max: number, pattern: Token]; + +/** + * Specifies a Unicode code point range from which to pick a random character. + */ +export type TokenRange = [type: 'range', min: number, max: number]; + +/** + * Executes a list of `what` tokens in sequence. + */ +export type TokenList = [type: 'list', what: Token[]]; + +/** + * Generates a random string based on the provided token. + * @param token The token defining the random string generation. + * @returns A randomly generated string. + */ +export function randomString(token: Token): string { + if (typeof token === 'string') return token; + const rnd = Math.random(); + switch (token[0]) { + case 'pick': { + const set = token[1]; + return set[Math.floor(rnd * set.length)]; + } + case 'repeat': { + const min = token[1]; + const max = token[2]; + const pattern = token[3]; + const count = Math.floor(rnd * (max - min + 1)) + min; + let str = ''; + for (let i = 0; i < count; i++) str += randomString(pattern); + return str; + } + case 'range': { + const min = token[1]; + const max = token[2]; + const codePoint = Math.floor(rnd * (max - min + 1)) + min; + return String.fromCodePoint(codePoint); + } + case 'list': + return token[1].map(randomString).join(''); + default: + throw new Error('Invalid token type'); + } +} diff --git a/yarn.lock b/yarn.lock index c71bd90..7c23116 100644 --- a/yarn.lock +++ b/yarn.lock @@ -570,6 +570,11 @@ resolved "https://registry.yarnpkg.com/@jsonjoy.com/buffers/-/buffers-1.0.0.tgz#ade6895b7d3883d70f87b5743efaa12c71dfef7a" integrity sha512-NDigYR3PHqCnQLXYyoLbnEdzMMvzeiCWo1KOut7Q0CoIqg9tUAPKJ1iq/2nFhc5kZtexzutNY0LFjdwWL3Dw3Q== +"@jsonjoy.com/codegen@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@jsonjoy.com/codegen/-/codegen-1.0.0.tgz#5c23f796c47675f166d23b948cdb889184b93207" + integrity sha512-E8Oy+08cmCf0EK/NMxpaJZmOxPqM+6iSe2S4nlSBrPZOORoDJILxtbSUEDKQyTamm/BVAhIGllOBNU79/dwf0g== + "@pkgjs/parseargs@^0.11.0": version "0.11.0" resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33"