Skip to content

Commit b741332

Browse files
authored
feat: Make extending custom collections easier (#467)
* feat: move tag.createNode to static nodeClass.from * feat(collections): use tagObj.nodeClass if present * feat: instantiate collection nodes with nodeClass * feat: custom map test, collection type match error * feat(docs): add collection examples to custom tag
1 parent a67a019 commit b741332

17 files changed

+316
-134
lines changed

docs/06_custom_tags.md

+86-3
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ These tags are a part of the YAML 1.1 [language-independent types](https://yaml.
6565
## Writing Custom Tags
6666

6767
```js
68-
import { stringify } from 'yaml'
68+
import { YAMLMap, stringify } from 'yaml'
6969
import { stringifyString } from 'yaml/util'
7070

7171
const regexp = {
@@ -89,18 +89,101 @@ const sharedSymbol = {
8989
}
9090
}
9191

92+
class YAMLNullObject extends YAMLMap {
93+
tag = '!nullobject'
94+
toJSON(_, ctx) {
95+
const obj = super.toJSON(_, { ...ctx, mapAsMap: false }, Object)
96+
return Object.assign(Object.create(null), obj)
97+
}
98+
}
99+
100+
const nullObject = {
101+
tag: '!nullobject',
102+
collection: 'map',
103+
nodeClass: YAMLNullObject,
104+
identify: v => !!(typeof v === 'object' && v && !Object.getPrototypeOf(v))
105+
}
106+
107+
// slightly more complicated object type
108+
class YAMLError extends YAMLMap {
109+
tag = '!error'
110+
toJSON(_, ctx) {
111+
const { name, message, stack, ...rest } = super.toJSON(
112+
_,
113+
{ ...ctx, mapAsMap: false },
114+
Object
115+
)
116+
// craft the appropriate error type
117+
const Cls =
118+
name === 'EvalError'
119+
? EvalError
120+
: name === 'RangeError'
121+
? RangeError
122+
: name === 'ReferenceError'
123+
? ReferenceError
124+
: name === 'SyntaxError'
125+
? SyntaxError
126+
: name === 'TypeError'
127+
? TypeError
128+
: name === 'URIError'
129+
? URIError
130+
: Error
131+
if (Cls.name !== name) {
132+
Object.defineProperty(er, 'name', {
133+
value: name,
134+
enumerable: false,
135+
configurable: true
136+
})
137+
}
138+
Object.defineProperty(er, 'stack', {
139+
value: stack,
140+
enumerable: false,
141+
configurable: true
142+
})
143+
return Object.assign(er, rest)
144+
}
145+
146+
static from(schema, obj, ctx) {
147+
const { name, message, stack } = obj
148+
// ensure these props remain, even if not enumerable
149+
return super.from(schema, { ...obj, name, message, stack }, ctx)
150+
}
151+
}
152+
153+
const error = {
154+
tag: '!error',
155+
collection: 'map',
156+
nodeClass: YAMLError,
157+
identify: v => !!(typeof v === 'object' && v && v instanceof Error)
158+
}
159+
92160
stringify(
93-
{ regexp: /foo/gi, symbol: Symbol.for('bar') },
94-
{ customTags: [regexp, sharedSymbol] }
161+
{
162+
regexp: /foo/gi,
163+
symbol: Symbol.for('bar'),
164+
nullobj: Object.assign(Object.create(null), { a: 1, b: 2 }),
165+
error: new Error('This was an error')
166+
},
167+
{ customTags: [regexp, sharedSymbol, nullObject, error] }
95168
)
96169
// regexp: !re /foo/gi
97170
// symbol: !symbol/shared bar
171+
// nullobj: !nullobject
172+
// a: 1
173+
// b: 2
174+
// error: !error
175+
// name: Error
176+
// message: 'This was an error'
177+
// stack: |
178+
// at some-file.js:1:3
98179
```
99180

100181
In YAML-speak, a custom data type is represented by a _tag_. To define your own tag, you need to account for the ways that your data is both parsed and stringified. Furthermore, both of those processes are split into two stages by the intermediate AST node structure.
101182

102183
If you wish to implement your own custom tags, the [`!!binary`](https://github.com/eemeli/yaml/blob/main/src/schema/yaml-1.1/binary.ts) and [`!!set`](https://github.com/eemeli/yaml/blob/main/src/schema/yaml-1.1/set.ts) tags provide relatively cohesive examples to study in addition to the simple examples in the sidebar here.
103184

185+
Custom collection types (ie, Maps, Sets, objects, and arrays; anything with child properties that may not be propertly serialized to a scalar value) may provide a `nodeClass` property that extends the [`YAMLMap`](https://github.com/eemeli/yaml/blob/main/src/nodes/YAMLMap.ts) and [`YAMLSeq`](https://github.com/eemeli/yaml/blob/main/src/nodes/YAMLSeq.ts) classes, which will be used for parsing and stringifying objects with the specified tag.
186+
104187
### Parsing Custom Data
105188

106189
At the lowest level, the [`Lexer`](#lexer) and [`Parser`](#parser) will take care of turning string input into a concrete syntax tree (CST).

src/compose/compose-collection.ts

+81-43
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { isMap, isNode } from '../nodes/identity.js'
1+
import { isNode } from '../nodes/identity.js'
22
import type { ParsedNode } from '../nodes/Node.js'
33
import { Scalar } from '../nodes/Scalar.js'
4-
import type { YAMLMap } from '../nodes/YAMLMap.js'
5-
import type { YAMLSeq } from '../nodes/YAMLSeq.js'
4+
import { YAMLMap } from '../nodes/YAMLMap.js'
5+
import { YAMLSeq } from '../nodes/YAMLSeq.js'
66
import type {
77
BlockMap,
88
BlockSequence,
@@ -16,68 +16,106 @@ import { resolveBlockMap } from './resolve-block-map.js'
1616
import { resolveBlockSeq } from './resolve-block-seq.js'
1717
import { resolveFlowCollection } from './resolve-flow-collection.js'
1818

19-
export function composeCollection(
19+
function resolveCollection(
2020
CN: ComposeNode,
2121
ctx: ComposeContext,
2222
token: BlockMap | BlockSequence | FlowCollection,
23-
tagToken: SourceToken | null,
24-
onError: ComposeErrorHandler
23+
onError: ComposeErrorHandler,
24+
tagName: string | null,
25+
tag?: CollectionTag
2526
) {
26-
let coll: YAMLMap.Parsed | YAMLSeq.Parsed
27-
switch (token.type) {
28-
case 'block-map': {
29-
coll = resolveBlockMap(CN, ctx, token, onError)
30-
break
31-
}
32-
case 'block-seq': {
33-
coll = resolveBlockSeq(CN, ctx, token, onError)
34-
break
35-
}
36-
case 'flow-collection': {
37-
coll = resolveFlowCollection(CN, ctx, token, onError)
38-
break
39-
}
40-
}
27+
const coll =
28+
token.type === 'block-map'
29+
? resolveBlockMap(CN, ctx, token, onError, tag)
30+
: token.type === 'block-seq'
31+
? resolveBlockSeq(CN, ctx, token, onError, tag)
32+
: resolveFlowCollection(CN, ctx, token, onError, tag)
4133

42-
if (!tagToken) return coll
43-
const tagName = ctx.directives.tagName(tagToken.source, msg =>
44-
onError(tagToken, 'TAG_RESOLVE_FAILED', msg)
45-
)
46-
if (!tagName) return coll
47-
48-
// Cast needed due to: https://github.com/Microsoft/TypeScript/issues/3841
4934
const Coll = coll.constructor as typeof YAMLMap | typeof YAMLSeq
35+
36+
// If we got a tagName matching the class, or the tag name is '!',
37+
// then use the tagName from the node class used to create it.
5038
if (tagName === '!' || tagName === Coll.tagName) {
5139
coll.tag = Coll.tagName
5240
return coll
5341
}
42+
if (tagName) coll.tag = tagName
43+
return coll
44+
}
45+
46+
export function composeCollection(
47+
CN: ComposeNode,
48+
ctx: ComposeContext,
49+
token: BlockMap | BlockSequence | FlowCollection,
50+
tagToken: SourceToken | null,
51+
onError: ComposeErrorHandler
52+
) {
53+
const tagName: string | null = !tagToken
54+
? null
55+
: ctx.directives.tagName(tagToken.source, msg =>
56+
onError(tagToken, 'TAG_RESOLVE_FAILED', msg)
57+
)
58+
59+
const expType: 'map' | 'seq' =
60+
token.type === 'block-map'
61+
? 'map'
62+
: token.type === 'block-seq'
63+
? 'seq'
64+
: token.start.source === '{'
65+
? 'map'
66+
: 'seq'
67+
68+
// shortcut: check if it's a generic YAMLMap or YAMLSeq
69+
// before jumping into the custom tag logic.
70+
if (
71+
!tagToken ||
72+
!tagName ||
73+
tagName === '!' ||
74+
(tagName === YAMLMap.tagName && expType === 'map') ||
75+
(tagName === YAMLSeq.tagName && expType === 'seq') ||
76+
!expType
77+
) {
78+
return resolveCollection(CN, ctx, token, onError, tagName)
79+
}
5480

55-
const expType = isMap(coll) ? 'map' : 'seq'
5681
let tag = ctx.schema.tags.find(
57-
t => t.collection === expType && t.tag === tagName
82+
t => t.tag === tagName && t.collection === expType
5883
) as CollectionTag | undefined
84+
5985
if (!tag) {
6086
const kt = ctx.schema.knownTags[tagName]
6187
if (kt && kt.collection === expType) {
6288
ctx.schema.tags.push(Object.assign({}, kt, { default: false }))
6389
tag = kt
6490
} else {
65-
onError(
66-
tagToken,
67-
'TAG_RESOLVE_FAILED',
68-
`Unresolved tag: ${tagName}`,
69-
true
70-
)
71-
coll.tag = tagName
72-
return coll
91+
if (kt?.collection) {
92+
onError(
93+
tagToken,
94+
'BAD_COLLECTION_TYPE',
95+
`${kt.tag} used for ${expType} collection, but expects ${kt.collection}`,
96+
true
97+
)
98+
} else {
99+
onError(
100+
tagToken,
101+
'TAG_RESOLVE_FAILED',
102+
`Unresolved tag: ${tagName}`,
103+
true
104+
)
105+
}
106+
return resolveCollection(CN, ctx, token, onError, tagName)
73107
}
74108
}
75109

76-
const res = tag.resolve(
77-
coll,
78-
msg => onError(tagToken, 'TAG_RESOLVE_FAILED', msg),
79-
ctx.options
80-
)
110+
const coll = resolveCollection(CN, ctx, token, onError, tagName, tag)
111+
112+
const res =
113+
tag.resolve?.(
114+
coll,
115+
msg => onError(tagToken, 'TAG_RESOLVE_FAILED', msg),
116+
ctx.options
117+
) ?? coll
118+
81119
const node = isNode(res)
82120
? (res as ParsedNode)
83121
: (new Scalar(res) as Scalar.Parsed)

src/compose/resolve-block-map.ts

+5-2
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { ParsedNode } from '../nodes/Node.js'
22
import { Pair } from '../nodes/Pair.js'
33
import { YAMLMap } from '../nodes/YAMLMap.js'
44
import type { BlockMap } from '../parse/cst.js'
5+
import { CollectionTag } from '../schema/types.js'
56
import type { ComposeContext, ComposeNode } from './compose-node.js'
67
import type { ComposeErrorHandler } from './composer.js'
78
import { resolveProps } from './resolve-props.js'
@@ -15,9 +16,11 @@ export function resolveBlockMap(
1516
{ composeNode, composeEmptyNode }: ComposeNode,
1617
ctx: ComposeContext,
1718
bm: BlockMap,
18-
onError: ComposeErrorHandler
19+
onError: ComposeErrorHandler,
20+
tag?: CollectionTag
1921
) {
20-
const map = new YAMLMap<ParsedNode, ParsedNode>(ctx.schema)
22+
const NodeClass = tag?.nodeClass ?? YAMLMap
23+
const map = new NodeClass(ctx.schema) as YAMLMap<ParsedNode, ParsedNode>
2124

2225
if (ctx.atRoot) ctx.atRoot = false
2326
let offset = bm.offset

src/compose/resolve-block-seq.ts

+5-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { YAMLSeq } from '../nodes/YAMLSeq.js'
22
import type { BlockSequence } from '../parse/cst.js'
3+
import { CollectionTag } from '../schema/types.js'
34
import type { ComposeContext, ComposeNode } from './compose-node.js'
45
import type { ComposeErrorHandler } from './composer.js'
56
import { resolveProps } from './resolve-props.js'
@@ -9,9 +10,11 @@ export function resolveBlockSeq(
910
{ composeNode, composeEmptyNode }: ComposeNode,
1011
ctx: ComposeContext,
1112
bs: BlockSequence,
12-
onError: ComposeErrorHandler
13+
onError: ComposeErrorHandler,
14+
tag?: CollectionTag
1315
) {
14-
const seq = new YAMLSeq(ctx.schema)
16+
const NodeClass = tag?.nodeClass ?? YAMLSeq
17+
const seq = new NodeClass(ctx.schema) as YAMLSeq
1518

1619
if (ctx.atRoot) ctx.atRoot = false
1720
let offset = bs.offset

src/compose/resolve-flow-collection.ts

+8-4
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { Pair } from '../nodes/Pair.js'
33
import { YAMLMap } from '../nodes/YAMLMap.js'
44
import { YAMLSeq } from '../nodes/YAMLSeq.js'
55
import type { FlowCollection, Token } from '../parse/cst.js'
6+
import { Schema } from '../schema/Schema.js'
7+
import { CollectionTag } from '../schema/types.js'
68
import type { ComposeContext, ComposeNode } from './compose-node.js'
79
import type { ComposeErrorHandler } from './composer.js'
810
import { resolveEnd } from './resolve-end.js'
@@ -18,13 +20,15 @@ export function resolveFlowCollection(
1820
{ composeNode, composeEmptyNode }: ComposeNode,
1921
ctx: ComposeContext,
2022
fc: FlowCollection,
21-
onError: ComposeErrorHandler
23+
onError: ComposeErrorHandler,
24+
tag?: CollectionTag
2225
) {
2326
const isMap = fc.start.source === '{'
2427
const fcName = isMap ? 'flow map' : 'flow sequence'
25-
const coll = isMap
26-
? (new YAMLMap(ctx.schema) as YAMLMap.Parsed)
27-
: (new YAMLSeq(ctx.schema) as YAMLSeq.Parsed)
28+
const NodeClass = (tag?.nodeClass ?? (isMap ? YAMLMap : YAMLSeq)) as {
29+
new (schema: Schema): YAMLMap.Parsed | YAMLSeq.Parsed
30+
}
31+
const coll = new NodeClass(ctx.schema)
2832
coll.flow = true
2933
const atRoot = ctx.atRoot
3034
if (atRoot) ctx.atRoot = false

src/doc/createNode.ts

+2
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,8 @@ export function createNode(
9999

100100
const node = tagObj?.createNode
101101
? tagObj.createNode(ctx.schema, value, ctx)
102+
: typeof tagObj?.nodeClass?.from === 'function'
103+
? tagObj.nodeClass.from(ctx.schema, value, ctx)
102104
: new Scalar(value)
103105
if (tagName) node.tag = tagName
104106
else if (!tagObj.default) node.tag = tagObj.tag

src/errors.ts

+1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export type ErrorCode =
2121
| 'TAB_AS_INDENT'
2222
| 'TAG_RESOLVE_FAILED'
2323
| 'UNEXPECTED_TOKEN'
24+
| 'BAD_COLLECTION_TYPE'
2425

2526
export type LinePos = { line: number; col: number }
2627

0 commit comments

Comments
 (0)