diff --git a/README.md b/README.md index 7081f35..4abb96b 100644 --- a/README.md +++ b/README.md @@ -27,11 +27,22 @@ const re = parse('!re /fo./g', { customTags: [regexp] }) - `symbol` (`!symbol`) - [Unique Symbols] - `nullobject` (`!nullobject) - Object with a `null` prototype - `error` (`!error`) - JavaScript [Error] objects +- `classTag` (`!class`) - JavaScript [Class] values +- `functionTag` (`!function`) - JavaScript [Function] values + (will also be used to stringify Class values, unless the + `classTag` tag is loaded ahead of `functionTag`) + +The function and class values created by parsing `!function` and +`!class` tags will not actually replicate running code, but +rather no-op function/class values with matching name and +`toString` properties. [RegExp]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions [Shared Symbols]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol#shared_symbols_in_the_global_symbol_registry [Unique Symbols]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol [Error]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error +[Function]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions +[Class]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes ## Customising Tag Names diff --git a/src/class.test.ts b/src/class.test.ts new file mode 100644 index 0000000..d89478e --- /dev/null +++ b/src/class.test.ts @@ -0,0 +1,92 @@ +import { test } from 'tap' +import { parse, stringify } from 'yaml' + +import { classTag, functionTag } from '.' + +test('parse valid', t => { + const res: { new (): any } = parse( + `!class |- +class X extends Y { + constructor() { + this.a = 1 + throw new Error('bad idea to actually run this') + } + z = 3 +} +`, + { customTags: [classTag, functionTag] } + ) + t.type(res, 'function') + t.equal( + res.toString(), + `class X extends Y { + constructor() { + this.a = 1 + throw new Error('bad idea to actually run this') + } + z = 3 +}` + ) + t.equal(res.name, 'X') + const inst = new res() + t.notMatch( + inst, + { + a: Number, + z: Number + }, + 'does not actually run the code specified' + ) + t.end() +}) + +test('unnamed class', t => { + // it's actually kind of tricky to get a class that V8 won't + // assign *some* sort of intelligible name to. It has to never be + // assigned to any variable, or directly pass as an argument to + // a named function at its point of creation, hence this line noise. + const res = stringify((() => class {})(), { + customTags: [classTag, functionTag] + }) + t.equal( + res, + `!class |- +class {\n } +` + ) + t.end() +}) + +test('parse completely empty value', t => { + const src = `!class |-\n` + const res: { new (): any } = parse(src, { customTags: [classTag] }) + t.type(res, 'function') + t.equal(res.name, undefined) + t.equal(res.toString(), '') + t.end() +}) + +class Foo extends Boolean {} +test('stringify a class', t => { + const res = stringify(Foo, { customTags: [classTag, functionTag] }) + // don't test the actual class body, because that will break + // if/when TypeScript is updated. + t.ok( + res.startsWith(`!class |- +class Foo extends Boolean {`), + 'shows class toString value' + ) + t.end() +}) + +test('stringify not a class for identify coverage', t => { + const res = stringify(() => {}, { customTags: [classTag, functionTag] }) + t.equal( + res, + `!function |- +"" +() => { } +` + ) + t.end() +}) diff --git a/src/class.ts b/src/class.ts new file mode 100644 index 0000000..da0c3c5 --- /dev/null +++ b/src/class.ts @@ -0,0 +1,48 @@ +import { Scalar, ScalarTag } from 'yaml' +import { stringifyString } from 'yaml/util' + +const options: { defaultType: Scalar.Type } = { + defaultType: 'BLOCK_LITERAL' +} + +/** + * `!class` A YAML representation of JavaScript classes + * + * Stringified as a block literal string, prefixed with the class name. + * + * When parsing, a no-op class with matching name and toString() is + * returned. It is not possible to construct an actual JavaScript class by + * evaluating YAML, and it is unsafe to attempt. + */ +export const classTag = { + identify(value) { + const cls = value as { new (): any } + try { + return typeof value === 'function' && Boolean(class extends cls {}) + } catch { + return false + } + }, + tag: '!class', + resolve(str) { + const f = class {} + f.toString = () => str + const m = str.trim().match(/^class(?:\s+([^{ \s]*?)[{\s])/) + Object.defineProperty(f, 'name', { + value: m?.[1], + enumerable: false, + configurable: true, + writable: true + }) + return f + }, + options, + stringify(i, ctx, onComment, onChompKeep) { + const { type: originalType, value: originalValue } = i + const cls = originalValue as { new (...a: any[]): any } + const value = cls.toString() + // better to just always put classes on a new line. + const type: Scalar.Type = originalType || options.defaultType + return stringifyString({ ...i, type, value }, ctx, onComment, onChompKeep) + } +} satisfies ScalarTag & { options: typeof options } diff --git a/src/function.test.ts b/src/function.test.ts new file mode 100644 index 0000000..9e2e767 --- /dev/null +++ b/src/function.test.ts @@ -0,0 +1,90 @@ +import { test } from 'tap' +import { parse, parseDocument, stringify } from 'yaml' + +import { functionTag } from '.' + +test('parse valid', t => { + const res: Function = parse( + `!function |- +"foo" +() => 'bar' +`, + { customTags: [functionTag] } + ) + t.type(res, 'function') + t.equal(res.toString(), `() => 'bar'`) + t.equal(res.name, 'foo') + t.equal(res(), undefined, 'does not actually run the code specified') + t.end() +}) + +test('parse invalid name serialization', t => { + const doc = parseDocument( + `!function |- +name: "invalid +blah +`, + { customTags: [functionTag] } + ) + t.has(doc.errors, { length: 1, 0: { code: 'TAG_RESOLVE_FAILED' } }) + t.end() +}) + +function foo() { + return 1 +} +test('stringify', t => { + const res = stringify(foo, { customTags: [functionTag] }) + // the divergent toString is because typescript parses this test + // prior to executing it, and it outputs JS with 4-space indentation + // and extraneous semicolons. + t.equal( + res, + `!function |- +"foo" +function foo() { + return 1; +} +` + ) + t.end() +}) + +test('unnamed function', t => { + // it's actually kind of tricky to get a function that V8 won't + // assign *some* sort of intelligible name to. It has to never be + // assigned to any variable, or directly pass as an argument to + // a named function at its point of creation, hence this line noise. + const res = stringify((() => () => {})(), { customTags: [functionTag] }) + t.equal( + res, + `!function |- +"" +() => { } +` + ) + t.end() +}) + +test('parse completely empty value', t => { + const src = `!function |-\n` + const res: Function = parse(src, { customTags: [functionTag] }) + t.type(res, 'function') + t.equal(res.name, undefined) + t.equal(res(), undefined) + t.end() +}) + +class Foo extends Boolean {} +test('stringify a class', t => { + const res = stringify(Foo, { customTags: [functionTag] }) + // don't test the actual class body, because that will break + // if/when TypeScript is updated. + t.ok( + res.startsWith(`!function |- +"Foo" +class Foo extends Boolean {`), + 'shows class toString value' + ) + t.end() +}) diff --git a/src/function.ts b/src/function.ts new file mode 100644 index 0000000..8db0f04 --- /dev/null +++ b/src/function.ts @@ -0,0 +1,43 @@ +import { Scalar, ScalarTag } from 'yaml' +import { stringifyString } from 'yaml/util' + +const options: { defaultType: Scalar.Type } = { + defaultType: 'BLOCK_LITERAL' +} + +/** + * `!function` A YAML representation of JavaScript functions + * + * Stringified as a block literal string, prefixed with the function name. + * + * When parsing, a no-op function with matching name and toString() is + * returned. It is not possible to construct an actual JavaScript function by + * evaluating YAML, and it is unsafe to attempt. + */ +export const functionTag = { + identify: value => typeof value === 'function', + tag: '!function', + resolve(str) { + const src = str.split('\n') + const n = src.shift() + const name = n ? JSON.parse(n) : undefined + const code = src.join('\n') + const f = function () {} + Object.defineProperty(f, 'name', { + value: name, + enumerable: false, + configurable: true + }) + f.toString = () => code + return f + }, + options, + stringify(i, ctx, onComment, onChompKeep) { + const { type: originalType, value: originalValue } = i + const fn = originalValue as (...a: any[]) => any + const value = JSON.stringify(fn.name) + '\n' + fn.toString() + // better to just always put functions on a new line. + const type: Scalar.Type = originalType || options.defaultType + return stringifyString({ ...i, type, value }, ctx, onComment, onChompKeep) + } +} satisfies ScalarTag & { options: typeof options } diff --git a/src/index.ts b/src/index.ts index f5b8590..f750e4e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,3 +2,5 @@ export { regexp } from './regexp.js' export { sharedSymbol, symbol } from './symbol.js' export { nullobject } from './null-object.js' export { error } from './error.js' +export { functionTag } from './function.js' +export { classTag } from './class.js'