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

Port several types from tap-yaml #3

Merged
merged 4 commits into from
May 19, 2023
Merged
Show file tree
Hide file tree
Changes from all 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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,24 @@ const re = parse('!re /fo./g', { customTags: [regexp] })
using their default `/foo/flags` string representation.
- `sharedSymbol` (`!symbol/shared`) - [Shared Symbols], i.e. ones created with `Symbol.for()`
- `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
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
"prettier": "^2.8.7",
"tap": "^16.3.4",
"typescript": "^5.0.4",
"yaml": "^2.3.0-4"
"yaml": "^2.3.0-5"
},
"peerDependencies": {
"yaml": "^2.3.0-4"
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 }
138 changes: 138 additions & 0 deletions src/error.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { test } from 'tap'
import { parse, stringify } from 'yaml'

import { inspect } from 'node:util'

import { error } from '.'
test('parse basic', t => {
const res: RangeError = parse(
`!error
name: RangeError
message: hello
stack: |2
RangeError: hello
at some-file.js:1:2
at Object.someMethod (/path/to/other-file.js:123:420)
`,
{ customTags: [error] }
)
t.type(res, RangeError)
t.match(res, {
name: 'RangeError',
message: 'hello',
stack: `RangeError: hello
at some-file.js:1:2
at Object.someMethod (/path/to/other-file.js:123:420)
`
})
t.end()
})

test('parse custom named error', t => {
const res: Error = parse(
`!error
name: CustomErrorSubclass
message: custom message
otherProperty: 123
`,
{ customTags: [error] }
)
t.type(res, Error)
t.match(res, {
name: 'CustomErrorSubclass',
message: 'custom message',
// if no stack in the yaml, none in the error object either
stack: undefined,
otherProperty: 123
})
t.end()
})

test('stringify basic', t => {
const e = new Error('message')
const res = stringify(e, { customTags: [error] })
t.match(res, /^!error\nname: Error\nmessage: message\nstack: /)
t.end()
})

test('stringify with custom inspect', t => {
const e = Object.assign(new URIError('blah'), {
// no real need to do this, stacks are just annoying to test,
// and we already verified above that gets a stack in the yaml.
stack: undefined,
cause: { foo: 'bar' },
a: true,
b: false,
c: 123,
[inspect.custom]: () => {
return { a: 1, b: 2 }
}
})
const res = stringify(e, { customTags: [error] })
t.equal(
res,
`!error
name: URIError
message: blah
cause:
foo: bar
a: 1
b: 2
`
)
t.end()
})

test('stringify with custom toJSON', t => {
const e = Object.assign(new URIError('blah'), {
// no real need to do this, stacks are just annoying to test,
// and we already verified above that gets a stack in the yaml.
stack: undefined,
a: true,
b: false,
c: 123,
toJSON: () => {
return { a: 1, b: 2 }
}
})
const res = stringify(e, { customTags: [error] })
t.equal(
res,
`!error
name: URIError
message: blah
a: 1
b: 2
`
)
t.end()
})

test('supports all known error types', t => {
const types: { new (m: string): Error }[] = [
EvalError,
RangeError,
ReferenceError,
SyntaxError,
TypeError,
URIError,
Error
]
t.plan(types.length)
for (const Cls of types) {
t.test(Cls.name, t => {
const e = new Cls('message')
const res = stringify(e, { customTags: [error] })
t.match(
res,
`!error
name: ${Cls.name}
message: message
`
)
const parsed = parse(res, { customTags: [error] })
t.type(parsed, Cls)
t.end()
})
}
})
Loading