Skip to content

Commit

Permalink
Refactor tag resolve() interface
Browse files Browse the repository at this point in the history
BREAKING CHANGE: All tag resolvers are now called with two arguments:
- value: string | YAMLMap | YAMLSeq
- onError(message: string): void
with the value being determined solely by the node's shape, rather than
any explicit tag it may have.
  • Loading branch information
eemeli committed Oct 3, 2020
1 parent 4309d78 commit 6eecd50
Show file tree
Hide file tree
Showing 18 changed files with 117 additions and 141 deletions.
4 changes: 2 additions & 2 deletions docs/06_custom_tags.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ If you wish to implement your own custom tags, the [`!!binary`](https://github.c

At the lowest level, [`YAML.parseCST()`](#cst-parser) will take care of turning string input into a concrete syntax tree (CST). In the CST all scalar values are available as strings, and maps & sequences as collections of nodes. Each schema includes a set of default data types, which handle converting at least strings, maps and sequences into their AST nodes. These are considered to have _implicit_ tags, and are autodetected. Custom tags, on the other hand, should almost always define an _explicit_ `tag` with which their value will be prefixed. This may be application-specific local `!tag`, a shorthand `!ns!tag`, or a verbatim `!<tag:example.com,2019:tag>`.

Once identified by matching the `tag`, the `resolve(doc, cstNode): Node | any` function will turn a CST node into an AST node. For scalars, this is relatively simple, as the stringified node value is directly available, and should be converted to its actual value. Collections are trickier, and it's almost certain that it'll make sense to use the `parseMap(doc, cstNode)` and `parseSeq(doc, cstNode)` functions exported from `'yaml/util'` to initially resolve the CST collection into a `YAMLMap` or `YAMLSeq` object, and to work with that instead -- this is for instance what the YAML 1.1 collections do.
Once identified by matching the `tag`, the `resolve(value, onError): Node | any` function will turn a parsed value into an AST node. `value` may be either a `string`, a `YAMLMap` or a `YAMLSeq`, depending on the node's shape. A custom tag should verify that value is of its expected type.

Note that during the CST -> AST parsing, the anchors and comments attached to each node are also resolved for each node. This metadata will unfortunately be lost when converting the values to JS objects, so collections should have values that extend one of the existing collection classes. Collections should therefore either fall back to their parent classes' `toJSON()` methods, or define their own in order to allow their contents to be expressed as the appropriate JS object.

Expand Down Expand Up @@ -145,7 +145,7 @@ To define your own tag, you'll need to define an object comprising of some of th
- **`identify(value): boolean`** is used by `doc.createNode()` to detect your data type, e.g. using `typeof` or `instanceof`. Required.
- `nodeClass: Node` is the `Node` child class that implements this tag. Required for collections and tags that have overlapping JS representations.
- `options: Object` is used by some tags to configure their stringification.
- **`resolve(doc, cstNode): Node | any`** turns a CST node into an AST node; `doc` is the resulting `YAML.Document` instance. If returning a non-`Node` value, the output will be wrapped as a `Scalar`. Required.
- **`resolve(value, onError): Node | any`** turns a parsed value into an AST node; `value` is either a `string`, a `YAMLMap` or a `YAMLSeq`. `onError(msg)` should be called with an error message string when encountering errors, as it'll allow you to still return some value for the node. If returning a non-`Node` value, the output will be wrapped as a `Scalar`. Required.
- `stringify(item, ctx, onComment, onChompKeep): string` is an optional function stringifying the `item` AST node in the current context `ctx`. `onComment` and `onChompKeep` are callback functions for a couple of special cases. If your data includes a suitable `.toString()` method, you can probably leave this undefined and use the default stringifier.
- **`tag: string`** is the identifier for your data type, with which its stringified form will be prefixed. Should either be a !-prefixed local `!tag`, or a fully qualified `tag:domain,date:foo`. Required.
- `test: RegExp` and `default: boolean` allow for values to be stringified without an explicit tag and detected using a regular expression. For most cases, it's unlikely that you'll actually want to use these, even if you first think you do.
5 changes: 0 additions & 5 deletions src/resolve/resolveMap.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,6 @@ import {
import { resolveNode } from './resolveNode.js'

export function resolveMap(doc, cst) {
if (cst.type !== Type.MAP && cst.type !== Type.FLOW_MAP) {
const msg = `A ${cst.type} node cannot be resolved as a mapping`
doc.errors.push(new YAMLSyntaxError(cst, msg))
return null
}
const { comments, items } =
cst.type === Type.FLOW_MAP
? resolveFlowMapItems(doc, cst)
Expand Down
7 changes: 5 additions & 2 deletions src/resolve/resolveNode.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
} from '../errors.js'

import { resolveScalar } from './resolveScalar.js'
import { resolveString } from './resolveString.js'
import { resolveTagName } from './resolveTagName.js'
import { resolveTag } from './resolveTag.js'

Expand Down Expand Up @@ -93,7 +92,11 @@ function resolveNodeValue(doc, node) {
}

try {
const str = resolveString(node.strValue, error => doc.errors.push(error))
let str = node.strValue || ''
if (typeof str !== 'string') {
str.errors.forEach(error => doc.errors.push(error))
str = str.str
}
return resolveScalar(str, schema.tags)
} catch (error) {
if (!error.source) error.source = node
Expand Down
5 changes: 0 additions & 5 deletions src/resolve/resolveSeq.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,6 @@ import {
import { resolveNode } from './resolveNode.js'

export function resolveSeq(doc, cst) {
if (cst.type !== Type.SEQ && cst.type !== Type.FLOW_SEQ) {
const msg = `A ${cst.type} node cannot be resolved as a sequence`
doc.errors.push(new YAMLSyntaxError(cst, msg))
return null
}
const { comments, items } =
cst.type === Type.FLOW_SEQ
? resolveFlowSeqItems(doc, cst)
Expand Down
8 changes: 0 additions & 8 deletions src/resolve/resolveString.js

This file was deleted.

75 changes: 47 additions & 28 deletions src/resolve/resolveTag.js
Original file line number Diff line number Diff line change
@@ -1,53 +1,73 @@
import { Collection } from '../ast/Collection.js'
import { Scalar } from '../ast/Scalar.js'
import { Type, defaultTags } from '../constants.js'
import { YAMLReferenceError, YAMLWarning } from '../errors.js'
import {
YAMLReferenceError,
YAMLSemanticError,
YAMLWarning
} from '../errors.js'
import { resolveMap } from './resolveMap.js'
import { resolveScalar } from './resolveScalar.js'
import { resolveString } from './resolveString.js'
import { resolveSeq } from './resolveSeq.js'

function resolveByTagName(doc, node, tagName) {
const { knownTags, tags } = doc.schema
function resolveByTagName({ knownTags, tags }, tagName, value, onError) {
const matchWithTest = []
for (const tag of tags) {
if (tag.tag === tagName) {
if (tag.test) matchWithTest.push(tag)
else {
const res = tag.resolve(doc, node)
if (tag.test) {
if (typeof value === 'string') matchWithTest.push(tag)
else onError(`The tag ${tagName} cannot be applied to a collection`)
} else {
const res = tag.resolve(value, onError)
return res instanceof Collection ? res : new Scalar(res)
}
}
}

const str = resolveString(node.strValue, error => doc.errors.push(error))
if (typeof str === 'string' && matchWithTest.length > 0)
return resolveScalar(str, matchWithTest)
if (matchWithTest.length > 0) return resolveScalar(value, matchWithTest)

const kt = knownTags[tagName]
if (kt) {
tags.push(Object.assign({}, kt, { default: false, test: undefined }))
const res = kt.resolve(doc, node)
const res = kt.resolve(value, onError)
return res instanceof Collection ? res : new Scalar(res)
}

return null
}

function getFallbackTagName({ type }) {
switch (type) {
case Type.FLOW_MAP:
case Type.MAP:
return defaultTags.MAP
case Type.FLOW_SEQ:
case Type.SEQ:
return defaultTags.SEQ
default:
return defaultTags.STR
}
}

export function resolveTag(doc, node, tagName) {
const { MAP, SEQ, STR } = defaultTags
let value, fallback
const onError = message =>
doc.errors.push(new YAMLSemanticError(node, message))
try {
const res = resolveByTagName(doc, node, tagName)
switch (node.type) {
case Type.FLOW_MAP:
case Type.MAP:
value = resolveMap(doc, node)
fallback = MAP
if (tagName === SEQ || tagName === STR)
onError(`The tag ${tagName} cannot be applied to a mapping`)
break
case Type.FLOW_SEQ:
case Type.SEQ:
value = resolveSeq(doc, node)
fallback = SEQ
if (tagName === MAP || tagName === STR)
onError(`The tag ${tagName} cannot be applied to a sequence`)
break
default:
value = node.strValue || ''
if (typeof value !== 'string') {
value.errors.forEach(error => doc.errors.push(error))
value = value.str
}
if (tagName === MAP || tagName === SEQ)
onError(`The tag ${tagName} cannot be applied to a scalar`)
fallback = STR
}

const res = resolveByTagName(doc.schema, tagName, value, onError)
if (res) {
if (tagName && node.tag) res.tag = tagName
return res
Expand All @@ -60,11 +80,10 @@ export function resolveTag(doc, node, tagName) {
}

try {
const fallback = getFallbackTagName(node)
if (!fallback) throw new Error(`The tag ${tagName} is unavailable`)
const msg = `The tag ${tagName} is unavailable, falling back to ${fallback}`
doc.warnings.push(new YAMLWarning(node, msg))
const res = resolveByTagName(doc, node, fallback)
const res = resolveByTagName(doc.schema, fallback, value, onError)
res.tag = tagName
return res
} catch (error) {
Expand Down
3 changes: 1 addition & 2 deletions src/tags/failsafe/map.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { createPair } from '../../ast/Pair.js'
import { YAMLMap } from '../../ast/YAMLMap.js'
import { resolveMap } from '../../resolve/resolveMap.js'

function createMap(schema, obj, ctx) {
const { keepUndefined, replacer } = ctx
Expand All @@ -27,5 +26,5 @@ export const map = {
default: true,
nodeClass: YAMLMap,
tag: 'tag:yaml.org,2002:map',
resolve: resolveMap
resolve: map => map
}
3 changes: 1 addition & 2 deletions src/tags/failsafe/seq.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { YAMLSeq } from '../../ast/YAMLSeq.js'
import { createNode } from '../../doc/createNode.js'
import { resolveSeq } from '../../resolve/resolveSeq.js'

function createSeq(schema, obj, ctx) {
const { replacer } = ctx
Expand All @@ -23,5 +22,5 @@ export const seq = {
default: true,
nodeClass: YAMLSeq,
tag: 'tag:yaml.org,2002:seq',
resolve: resolveSeq
resolve: seq => seq
}
4 changes: 1 addition & 3 deletions src/tags/failsafe/string.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,11 @@
import { resolveString } from '../../resolve/resolveString.js'
import { stringifyString } from '../../stringify/stringifyString.js'
import { strOptions } from '../options.js'

export const string = {
identify: value => typeof value === 'string',
default: true,
tag: 'tag:yaml.org,2002:str',
resolve: (doc, node) =>
resolveString(node.strValue, error => doc.errors.push(error)),
resolve: str => str,
stringify(item, ctx, onComment, onChompKeep) {
ctx = Object.assign({ actualString: true }, ctx)
return stringifyString(item, ctx, onComment, onChompKeep)
Expand Down
9 changes: 4 additions & 5 deletions src/tags/json.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
/* global BigInt */

import { Scalar } from '../ast/Scalar.js'
import { resolveString } from '../resolve/resolveString.js'
import { map } from './failsafe/map.js'
import { seq } from './failsafe/seq.js'
import { intOptions } from './options.js'
Expand All @@ -18,8 +17,7 @@ export const json = [
identify: value => typeof value === 'string',
default: true,
tag: 'tag:yaml.org,2002:str',
resolve: (doc, node) =>
resolveString(node.strValue, error => doc.errors.push(error)),
resolve: str => str,
stringify: stringifyJSON
},
{
Expand Down Expand Up @@ -60,8 +58,9 @@ export const json = [
{
default: true,
test: /^/,
resolve(str) {
throw new SyntaxError(`Unresolved plain scalar ${JSON.stringify(str)}`)
resolve(str, onError) {
onError(`Unresolved plain scalar ${JSON.stringify(str)}`)
return str
}
}
]
11 changes: 4 additions & 7 deletions src/tags/yaml-1.1/binary.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
/* global atob, btoa, Buffer */

import { Type } from '../../constants.js'
import { YAMLReferenceError } from '../../errors.js'
import { resolveString } from '../../resolve/resolveString.js'
import { stringifyString } from '../../stringify/stringifyString.js'
import { binaryOptions as options } from '../options.js'

Expand All @@ -18,8 +16,7 @@ export const binary = {
* const blob = new Blob([buffer], { type: 'image/jpeg' })
* document.querySelector('#photo').src = URL.createObjectURL(blob)
*/
resolve: (doc, node) => {
const src = resolveString(node.strValue, error => doc.errors.push(error))
resolve(src, onError) {
if (typeof Buffer === 'function') {
return Buffer.from(src, 'base64')
} else if (typeof atob === 'function') {
Expand All @@ -29,10 +26,10 @@ export const binary = {
for (let i = 0; i < str.length; ++i) buffer[i] = str.charCodeAt(i)
return buffer
} else {
const msg =
onError(
'This environment does not support reading binary tags; either Buffer or atob is required'
doc.errors.push(new YAMLReferenceError(node, msg))
return null
)
return src
}
},
options,
Expand Down
7 changes: 3 additions & 4 deletions src/tags/yaml-1.1/omap.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,13 @@ export class YAMLOMap extends YAMLSeq {
}
}

function parseOMap(doc, cst) {
const pairs = parsePairs(doc, cst)
function parseOMap(seq, onError) {
const pairs = parsePairs(seq, onError)
const seenKeys = []
for (const { key } of pairs.items) {
if (key instanceof Scalar) {
if (seenKeys.includes(key.value)) {
const msg = 'Ordered maps must not include duplicate keys'
throw new YAMLSemanticError(cst, msg)
onError(`Ordered maps must not include duplicate keys: ${key.value}`)
} else {
seenKeys.push(key.value)
}
Expand Down
43 changes: 20 additions & 23 deletions src/tags/yaml-1.1/pairs.js
Original file line number Diff line number Diff line change
@@ -1,32 +1,29 @@
import { YAMLSemanticError } from '../../errors.js'
import { createPair, Pair } from '../../ast/Pair.js'
import { YAMLMap } from '../../ast/YAMLMap.js'
import { YAMLSeq } from '../../ast/YAMLSeq.js'
import { resolveSeq } from '../../resolve/resolveSeq.js'

export function parsePairs(doc, cst) {
const seq = resolveSeq(doc, cst)
for (let i = 0; i < seq.items.length; ++i) {
let item = seq.items[i]
if (item instanceof Pair) continue
else if (item instanceof YAMLMap) {
if (item.items.length > 1) {
const msg = 'Each pair must have its own sequence indicator'
throw new YAMLSemanticError(cst, msg)
export function parsePairs(seq, onError) {
if (seq instanceof YAMLSeq) {
for (let i = 0; i < seq.items.length; ++i) {
let item = seq.items[i]
if (item instanceof Pair) continue
else if (item instanceof YAMLMap) {
if (item.items.length > 1)
onError('Each pair must have its own sequence indicator')
const pair = item.items[0] || new Pair()
if (item.commentBefore)
pair.commentBefore = pair.commentBefore
? `${item.commentBefore}\n${pair.commentBefore}`
: item.commentBefore
if (item.comment)
pair.comment = pair.comment
? `${item.comment}\n${pair.comment}`
: item.comment
item = pair
}
const pair = item.items[0] || new Pair()
if (item.commentBefore)
pair.commentBefore = pair.commentBefore
? `${item.commentBefore}\n${pair.commentBefore}`
: item.commentBefore
if (item.comment)
pair.comment = pair.comment
? `${item.comment}\n${pair.comment}`
: item.comment
item = pair
seq.items[i] = item instanceof Pair ? item : new Pair(item)
}
seq.items[i] = item instanceof Pair ? item : new Pair(item)
}
} else onError('Expected a sequence for this tag')
return seq
}

Expand Down
13 changes: 6 additions & 7 deletions src/tags/yaml-1.1/set.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { YAMLSemanticError } from '../../errors.js'
import { createPair, Pair } from '../../ast/Pair.js'
import { Scalar } from '../../ast/Scalar.js'
import { YAMLMap, findPair } from '../../ast/YAMLMap.js'
import { resolveMap } from '../../resolve/resolveMap.js'

export class YAMLSet extends YAMLMap {
static tag = 'tag:yaml.org,2002:set'
Expand Down Expand Up @@ -52,11 +50,12 @@ export class YAMLSet extends YAMLMap {
}
}

function parseSet(doc, cst) {
const map = resolveMap(doc, cst)
if (!map.hasAllNullValues())
throw new YAMLSemanticError(cst, 'Set items must all have null values')
return Object.assign(new YAMLSet(), map)
function parseSet(map, onError) {
if (map instanceof YAMLMap) {
if (map.hasAllNullValues()) return Object.assign(new YAMLSet(), map)
else onError('Set items must all have null values')
} else onError('Expected a mapping for this tag')
return map
}

function createSet(schema, iterable, ctx) {
Expand Down

0 comments on commit 6eecd50

Please sign in to comment.