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

easier extending custom collections #467

Merged
merged 11 commits into from
May 6, 2023
90 changes: 87 additions & 3 deletions docs/06_custom_tags.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ These tags are a part of the YAML 1.1 [language-independent types](https://yaml.
## Writing Custom Tags

```js
import { stringify } from 'yaml'
import { YAMLMap, stringify } from 'yaml'
import { stringifyString } from 'yaml/util'

const regexp = {
Expand All @@ -89,18 +89,102 @@ const sharedSymbol = {
}
}

class YAMLNullObject extends YAMLMap {
tag = '!nullobject'
toJSON(_, ctx) {
const obj = super.toJSON(_, { ...ctx, mapAsMap: false }, Object)
return Object.assign(Object.create(null), obj)
}
}

const nullObject = {
tag: '!nullobject',
collection: 'map',
nodeClass: YAMLNullObject,
identify: v => !!(
typeof v === 'object' &&
v &&
!Object.getPrototypeOf(v)
)
}

// slightly more complicated object type
class YAMLError extends YAMLMap {
isaacs marked this conversation as resolved.
Show resolved Hide resolved
tag = '!error'
toJSON(_, ctx) {
const { name, message, stack, ...rest } = super.toJSON(_, {
...ctx,
mapAsMap: false,
}, Object)
// craft the appropriate error type
const Cls =
name === 'EvalError' ? EvalError
: name === 'RangeError' ? RangeError
: name === 'ReferenceError' ? ReferenceError
: name === 'SyntaxError' ? SyntaxError
: name === 'TypeError' ? TypeError
: name === 'URIError' ? URIError
: Error
if (Cls.name !== name) {
Object.defineProperty(er, 'name', {
value: name,
enumerable: false,
configurable: true,
})
}
Object.defineProperty(er, 'stack', {
value: stack,
enumerable: false,
configurable: true,
})
return Object.assign(er, rest)
}

static from (schema, obj, ctx) {
const { name, message, stack } = obj
// ensure these props remain, even if not enumerable
return super.from(schema, { ...obj, name, message, stack }, ctx)
}
}

const error = {
tag: '!error',
collection: 'map',
nodeClass: YAMLError,
identify: v => !!(
typeof v === 'object' &&
v &&
v instanceof Error
)
}

stringify(
{ regexp: /foo/gi, symbol: Symbol.for('bar') },
{ customTags: [regexp, sharedSymbol] }
{
regexp: /foo/gi,
symbol: Symbol.for('bar'),
nullobj: Object.assign(Object.create(null), { a: 1, b: 2 }),
error: new Error('This was an error'),
},
{ customTags: [regexp, sharedSymbol, nullObject, error] }
)
// regexp: !re /foo/gi
// symbol: !symbol/shared bar
// nullobj: !nullobject
// a: 1
// b: 2
// error: !error
// name: Error
// message: 'This was an error'
// stack: |
// at some-file.js:1:3
```

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.

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.

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.

### Parsing Custom Data

At the lowest level, the [`Lexer`](#lexer) and [`Parser`](#parser) will take care of turning string input into a concrete syntax tree (CST).
Expand Down
149 changes: 105 additions & 44 deletions src/compose/compose-collection.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { isMap, isNode } from '../nodes/identity.js'
import { isNode } from '../nodes/identity.js'
import type { ParsedNode } from '../nodes/Node.js'
import { Scalar } from '../nodes/Scalar.js'
import type { YAMLMap } from '../nodes/YAMLMap.js'
import type { YAMLSeq } from '../nodes/YAMLSeq.js'
import { YAMLMap } from '../nodes/YAMLMap.js'
import { YAMLSeq } from '../nodes/YAMLSeq.js'
import type {
BlockMap,
BlockSequence,
Expand All @@ -16,68 +16,129 @@ import { resolveBlockMap } from './resolve-block-map.js'
import { resolveBlockSeq } from './resolve-block-seq.js'
import { resolveFlowCollection } from './resolve-flow-collection.js'

function resolveCollection(
CN: ComposeNode,
ctx: ComposeContext,
token: BlockMap | BlockSequence | FlowCollection,
onError: ComposeErrorHandler,
tagName: string | null,
tag?: CollectionTag
) {
const coll =
token.type === 'block-map'
? resolveBlockMap(CN, ctx, token, onError, tag)
: token.type === 'block-seq'
? resolveBlockSeq(CN, ctx, token, onError, tag)
: resolveFlowCollection(CN, ctx, token, onError, tag)

const Coll = coll.constructor as typeof YAMLMap | typeof YAMLSeq

// If we got a tagName matching the class, or the tag name is '!',
// then use the tagName from the node class used to create it.
if (tagName === '!' || tagName === Coll.tagName) {
coll.tag = Coll.tagName
return coll
}
if (tagName) coll.tag = tagName
return coll
}

export function composeCollection(
CN: ComposeNode,
ctx: ComposeContext,
token: BlockMap | BlockSequence | FlowCollection,
tagToken: SourceToken | null,
onError: ComposeErrorHandler
) {
let coll: YAMLMap.Parsed | YAMLSeq.Parsed
switch (token.type) {
case 'block-map': {
coll = resolveBlockMap(CN, ctx, token, onError)
break
}
case 'block-seq': {
coll = resolveBlockSeq(CN, ctx, token, onError)
break
}
case 'flow-collection': {
coll = resolveFlowCollection(CN, ctx, token, onError)
break
}
const tagName: string | null = !tagToken
? null
: ctx.directives.tagName(tagToken.source, msg =>
onError(tagToken, 'TAG_RESOLVE_FAILED', msg)
)

let expType: 'map' | 'seq' | undefined =
token.type === 'block-map'
? 'map'
: token.type === 'block-seq'
? 'seq'
: token.type === 'flow-collection'
isaacs marked this conversation as resolved.
Show resolved Hide resolved
? token.start.source === '{'
? 'map'
: 'seq'
: undefined

if (!expType) {
onError(
token,
'IMPOSSIBLE',
'could not determine collection expression type',
true
)
}

if (!tagToken) return coll
const tagName = ctx.directives.tagName(tagToken.source, msg =>
onError(tagToken, 'TAG_RESOLVE_FAILED', msg)
)
if (!tagName) return coll
// shortcut: check if it's a generic YAMLMap or YAMLSeq
// before jumping into the custom tag logic.
if (
!tagToken ||
!tagName ||
tagName === '!' ||
(tagName === YAMLMap.tagName && expType === 'map') ||
(tagName === YAMLSeq.tagName && expType === 'seq') ||
!expType
) {
return resolveCollection(CN, ctx, token, onError, tagName)
}

// Cast needed due to: https://github.com/Microsoft/TypeScript/issues/3841
const Coll = coll.constructor as typeof YAMLMap | typeof YAMLSeq
if (tagName === '!' || tagName === Coll.tagName) {
coll.tag = Coll.tagName
return coll
let tag = ctx.schema.tags.find(t => t.tag === tagName) as
| CollectionTag
| undefined
if (tag && tag.collection !== expType) {
if (tag.collection) {
onError(
tagToken,
'BAD_COLLECTION_TYPE',
`${tag.tag} used for ${expType} collection, but expects ${tag.collection}`,
true
)
return resolveCollection(CN, ctx, token, onError, tagName)
}
tag = undefined
isaacs marked this conversation as resolved.
Show resolved Hide resolved
}

const expType = isMap(coll) ? 'map' : 'seq'
let tag = ctx.schema.tags.find(
t => t.collection === expType && t.tag === tagName
) as CollectionTag | undefined
if (!tag) {
const kt = ctx.schema.knownTags[tagName]
if (kt && kt.collection === expType) {
ctx.schema.tags.push(Object.assign({}, kt, { default: false }))
tag = kt
} else {
onError(
tagToken,
'TAG_RESOLVE_FAILED',
`Unresolved tag: ${tagName}`,
true
)
coll.tag = tagName
return coll
if (kt?.collection) {
onError(
tagToken,
'BAD_COLLECTION_TYPE',
`${kt.tag} used for ${expType} collection, but expects ${kt.collection}`,
true
)
} else {
onError(
tagToken,
'TAG_RESOLVE_FAILED',
`Unresolved tag: ${tagName}`,
true
)
}
return resolveCollection(CN, ctx, token, onError, tagName)
}
}

const res = tag.resolve(
coll,
msg => onError(tagToken, 'TAG_RESOLVE_FAILED', msg),
ctx.options
)
const coll = resolveCollection(CN, ctx, token, onError, tagName, tag)

const res =
tag.resolve?.(
coll,
msg => onError(tagToken, 'TAG_RESOLVE_FAILED', msg),
ctx.options
) ?? coll

const node = isNode(res)
? (res as ParsedNode)
: (new Scalar(res) as Scalar.Parsed)
Expand Down
7 changes: 5 additions & 2 deletions src/compose/resolve-block-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { ParsedNode } from '../nodes/Node.js'
import { Pair } from '../nodes/Pair.js'
import { YAMLMap } from '../nodes/YAMLMap.js'
import type { BlockMap } from '../parse/cst.js'
import { CollectionTag } from '../schema/types.js'
import type { ComposeContext, ComposeNode } from './compose-node.js'
import type { ComposeErrorHandler } from './composer.js'
import { resolveProps } from './resolve-props.js'
Expand All @@ -15,9 +16,11 @@ export function resolveBlockMap(
{ composeNode, composeEmptyNode }: ComposeNode,
ctx: ComposeContext,
bm: BlockMap,
onError: ComposeErrorHandler
onError: ComposeErrorHandler,
tag?: CollectionTag
) {
const map = new YAMLMap<ParsedNode, ParsedNode>(ctx.schema)
const NodeClass = tag?.nodeClass || YAMLMap
const map = new NodeClass(ctx.schema) as YAMLMap<ParsedNode, ParsedNode>

if (ctx.atRoot) ctx.atRoot = false
let offset = bm.offset
Expand Down
7 changes: 5 additions & 2 deletions src/compose/resolve-block-seq.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { YAMLSeq } from '../nodes/YAMLSeq.js'
import type { BlockSequence } from '../parse/cst.js'
import { CollectionTag } from '../schema/types.js'
import type { ComposeContext, ComposeNode } from './compose-node.js'
import type { ComposeErrorHandler } from './composer.js'
import { resolveProps } from './resolve-props.js'
Expand All @@ -9,9 +10,11 @@ export function resolveBlockSeq(
{ composeNode, composeEmptyNode }: ComposeNode,
ctx: ComposeContext,
bs: BlockSequence,
onError: ComposeErrorHandler
onError: ComposeErrorHandler,
tag?: CollectionTag
) {
const seq = new YAMLSeq(ctx.schema)
const NodeClass = tag?.nodeClass || YAMLSeq
const seq = new NodeClass(ctx.schema) as YAMLSeq

if (ctx.atRoot) ctx.atRoot = false
let offset = bs.offset
Expand Down
11 changes: 7 additions & 4 deletions src/compose/resolve-flow-collection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Pair } from '../nodes/Pair.js'
import { YAMLMap } from '../nodes/YAMLMap.js'
import { YAMLSeq } from '../nodes/YAMLSeq.js'
import type { FlowCollection, Token } from '../parse/cst.js'
import { CollectionTag } from '../schema/types.js'
import type { ComposeContext, ComposeNode } from './compose-node.js'
import type { ComposeErrorHandler } from './composer.js'
import { resolveEnd } from './resolve-end.js'
Expand All @@ -18,13 +19,15 @@ export function resolveFlowCollection(
{ composeNode, composeEmptyNode }: ComposeNode,
ctx: ComposeContext,
fc: FlowCollection,
onError: ComposeErrorHandler
onError: ComposeErrorHandler,
tag?: CollectionTag
) {
const isMap = fc.start.source === '{'
const fcName = isMap ? 'flow map' : 'flow sequence'
const coll = isMap
? (new YAMLMap(ctx.schema) as YAMLMap.Parsed)
: (new YAMLSeq(ctx.schema) as YAMLSeq.Parsed)
let NodeClass = (tag?.nodeClass ?? (isMap ? YAMLMap : YAMLSeq)) as {
new (ctx: ComposeContext['schema']): YAMLMap.Parsed | YAMLSeq.Parsed
}
const coll = new NodeClass(ctx.schema)
coll.flow = true
const atRoot = ctx.atRoot
if (atRoot) ctx.atRoot = false
Expand Down
2 changes: 2 additions & 0 deletions src/doc/createNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ export function createNode(

const node = tagObj?.createNode
? tagObj.createNode(ctx.schema, value, ctx)
: typeof tagObj?.nodeClass?.from === 'function'
? tagObj.nodeClass.from(ctx.schema, value, ctx)
: new Scalar(value)
if (tagName) node.tag = tagName
else if (!tagObj.default) node.tag = tagObj.tag
Expand Down
1 change: 1 addition & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export type ErrorCode =
| 'TAB_AS_INDENT'
| 'TAG_RESOLVE_FAILED'
| 'UNEXPECTED_TOKEN'
| 'BAD_COLLECTION_TYPE'

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

Expand Down
Loading