Skip to content

Commit

Permalink
feat: add !class and !function tags
Browse files Browse the repository at this point in the history
  • Loading branch information
isaacs committed May 19, 2023
1 parent 93f783d commit 4ad7082
Show file tree
Hide file tree
Showing 6 changed files with 286 additions and 0 deletions.
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
92 changes: 92 additions & 0 deletions src/class.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
48 changes: 48 additions & 0 deletions src/class.ts
Original file line number Diff line number Diff line change
@@ -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 }
90 changes: 90 additions & 0 deletions src/function.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
43 changes: 43 additions & 0 deletions src/function.ts
Original file line number Diff line number Diff line change
@@ -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 }
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

0 comments on commit 4ad7082

Please sign in to comment.