diff --git a/docs/06_custom_tags.md b/docs/06_custom_tags.md index 1f8d6cf5..c198784c 100644 --- a/docs/06_custom_tags.md +++ b/docs/06_custom_tags.md @@ -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 `!`. -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. @@ -128,8 +128,6 @@ Finally, `stringify(item, ctx, ...): string` defines how your data should be rep ```js import { findPair, // (items, key) => Pair? -- Given a key, find a matching Pair - parseMap, // (doc, cstNode) => new YAMLMap - parseSeq, // (doc, cstNode) => new YAMLSeq stringifyNumber, // (node) => string stringifyString, // (node, ctx, ...) => string toJS, // (value, arg, ctx) => any -- Recursively convert to plain JS @@ -145,7 +143,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. diff --git a/src/resolve/resolveMap.js b/src/resolve/resolveMap.js index d2f6e4ca..a7d8870d 100644 --- a/src/resolve/resolveMap.js +++ b/src/resolve/resolveMap.js @@ -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) diff --git a/src/resolve/resolveNode.js b/src/resolve/resolveNode.js index 3b76f372..3959eb9d 100644 --- a/src/resolve/resolveNode.js +++ b/src/resolve/resolveNode.js @@ -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' @@ -93,8 +92,12 @@ function resolveNodeValue(doc, node) { } try { - const str = resolveString(doc, node) - return resolveScalar(str, schema.tags, schema.tags.scalarFallback) + 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 errors.push(error) diff --git a/src/resolve/resolveScalar.js b/src/resolve/resolveScalar.js index 5512f75f..d2506976 100644 --- a/src/resolve/resolveScalar.js +++ b/src/resolve/resolveScalar.js @@ -1,18 +1,13 @@ import { Scalar } from '../ast/Scalar.js' -// falls back to string on no match -export function resolveScalar(str, tags, scalarFallback) { +export function resolveScalar(str, tags) { for (const { format, test, resolve } of tags) { - if (test) { - const match = str.match(test) - if (match) { - let res = resolve.apply(null, match) - if (!(res instanceof Scalar)) res = new Scalar(res) - if (format) res.format = format - return res - } + if (test && test.test(str)) { + let res = resolve(str) + if (!(res instanceof Scalar)) res = new Scalar(res) + if (format) res.format = format + return res } } - if (scalarFallback) str = scalarFallback(str) - return new Scalar(str) + return new Scalar(str) // fallback to string } diff --git a/src/resolve/resolveSeq.js b/src/resolve/resolveSeq.js index b6870a7e..5b5d1ab1 100644 --- a/src/resolve/resolveSeq.js +++ b/src/resolve/resolveSeq.js @@ -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) diff --git a/src/resolve/resolveString.js b/src/resolve/resolveString.js deleted file mode 100644 index 900c40e2..00000000 --- a/src/resolve/resolveString.js +++ /dev/null @@ -1,11 +0,0 @@ -// on error, will return { str: string, errors: Error[] } -export function resolveString(doc, node) { - const res = node.strValue - if (!res) return '' - if (typeof res === 'string') return res - res.errors.forEach(error => { - if (!error.source) error.source = node - doc.errors.push(error) - }) - return res.str -} diff --git a/src/resolve/resolveTag.js b/src/resolve/resolveTag.js index 5930d1b1..df891269 100644 --- a/src/resolve/resolveTag.js +++ b/src/resolve/resolveTag.js @@ -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(doc, node) - if (typeof str === 'string' && matchWithTest.length > 0) - return resolveScalar(str, matchWithTest, tags.scalarFallback) + 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 @@ -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) { diff --git a/src/stringify/stringifyString.js b/src/stringify/stringifyString.js index fc0af359..8d2ad65f 100644 --- a/src/stringify/stringifyString.js +++ b/src/stringify/stringifyString.js @@ -260,7 +260,7 @@ function plainString(item, ctx, onComment, onChompKeep) { // and others in v1.1. if (actualString) { const { tags } = ctx.doc.schema - const resolved = resolveScalar(str, tags, tags.scalarFallback).value + const resolved = resolveScalar(str, tags).value if (typeof resolved !== 'string') return doubleQuotedString(value, ctx) } const body = implicitKey diff --git a/src/tags/core.js b/src/tags/core.js index c5a500ab..82536e86 100644 --- a/src/tags/core.js +++ b/src/tags/core.js @@ -8,8 +8,8 @@ import { boolOptions, intOptions, nullOptions } from './options.js' const intIdentify = value => typeof value === 'bigint' || Number.isInteger(value) -const intResolve = (src, part, radix) => - intOptions.asBigInt ? BigInt(src) : parseInt(part, radix) +const intResolve = (src, offset, radix) => + intOptions.asBigInt ? BigInt(src) : parseInt(src.substring(offset), radix) function intStringify(node, radix, prefix) { const { value } = node @@ -48,8 +48,8 @@ export const octObj = { default: true, tag: 'tag:yaml.org,2002:int', format: 'OCT', - test: /^0o([0-7]+)$/, - resolve: (str, oct) => intResolve(str, oct, 8), + test: /^0o[0-7]+$/, + resolve: str => intResolve(str, 2, 8), options: intOptions, stringify: node => intStringify(node, 8, '0o') } @@ -59,7 +59,7 @@ export const intObj = { default: true, tag: 'tag:yaml.org,2002:int', test: /^[-+]?[0-9]+$/, - resolve: str => intResolve(str, str, 10), + resolve: str => intResolve(str, 0, 10), options: intOptions, stringify: stringifyNumber } @@ -69,8 +69,8 @@ export const hexObj = { default: true, tag: 'tag:yaml.org,2002:int', format: 'HEX', - test: /^0x([0-9a-fA-F]+)$/, - resolve: (str, hex) => intResolve(str, hex, 16), + test: /^0x[0-9a-fA-F]+$/, + resolve: str => intResolve(str, 2, 16), options: intOptions, stringify: node => intStringify(node, 16, '0x') } @@ -79,9 +79,9 @@ export const nanObj = { identify: value => typeof value === 'number', default: true, tag: 'tag:yaml.org,2002:float', - test: /^(?:[-+]?\.inf|(\.nan))$/i, - resolve: (str, nan) => - nan + test: /^(?:[-+]?\.(?:inf|Inf|INF|nan|NaN|NAN))$/, + resolve: str => + str.slice(-3).toLowerCase() === 'nan' ? NaN : str[0] === '-' ? Number.NEGATIVE_INFINITY @@ -103,12 +103,12 @@ export const floatObj = { identify: value => typeof value === 'number', default: true, tag: 'tag:yaml.org,2002:float', - test: /^[-+]?(?:\.([0-9]+)|[0-9]+\.([0-9]*))$/, - resolve(str, frac1, frac2) { - const frac = frac1 || frac2 + test: /^[-+]?(?:\.[0-9]+|[0-9]+\.[0-9]*)$/, + resolve(str) { const node = new Scalar(parseFloat(str)) - if (frac && frac[frac.length - 1] === '0') - node.minFractionDigits = frac.length + const dot = str.indexOf('.') + if (dot !== -1 && str[str.length - 1] === '0') + node.minFractionDigits = str.length - dot - 1 return node }, stringify: stringifyNumber diff --git a/src/tags/failsafe/map.js b/src/tags/failsafe/map.js index 7041562c..2109c294 100644 --- a/src/tags/failsafe/map.js +++ b/src/tags/failsafe/map.js @@ -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 @@ -27,5 +26,5 @@ export const map = { default: true, nodeClass: YAMLMap, tag: 'tag:yaml.org,2002:map', - resolve: resolveMap + resolve: map => map } diff --git a/src/tags/failsafe/seq.js b/src/tags/failsafe/seq.js index cd78efdf..9cb52148 100644 --- a/src/tags/failsafe/seq.js +++ b/src/tags/failsafe/seq.js @@ -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 @@ -23,5 +22,5 @@ export const seq = { default: true, nodeClass: YAMLSeq, tag: 'tag:yaml.org,2002:seq', - resolve: resolveSeq + resolve: seq => seq } diff --git a/src/tags/failsafe/string.js b/src/tags/failsafe/string.js index a6281c1f..f1cd90d9 100644 --- a/src/tags/failsafe/string.js +++ b/src/tags/failsafe/string.js @@ -1,4 +1,3 @@ -import { resolveString } from '../../resolve/resolveString.js' import { stringifyString } from '../../stringify/stringifyString.js' import { strOptions } from '../options.js' @@ -6,7 +5,7 @@ export const string = { identify: value => typeof value === 'string', default: true, tag: 'tag:yaml.org,2002:str', - resolve: resolveString, + resolve: str => str, stringify(item, ctx, onComment, onChompKeep) { ctx = Object.assign({ actualString: true }, ctx) return stringifyString(item, ctx, onComment, onChompKeep) diff --git a/src/tags/json.js b/src/tags/json.js index f4c10990..abe6f7fe 100644 --- a/src/tags/json.js +++ b/src/tags/json.js @@ -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' @@ -18,7 +17,7 @@ export const json = [ identify: value => typeof value === 'string', default: true, tag: 'tag:yaml.org,2002:str', - resolve: resolveString, + resolve: str => str, stringify: stringifyJSON }, { @@ -55,9 +54,13 @@ export const json = [ test: /^-?(?:0|[1-9][0-9]*)(?:\.[0-9]*)?(?:[eE][-+]?[0-9]+)?$/, resolve: str => parseFloat(str), stringify: stringifyJSON + }, + { + default: true, + test: /^/, + resolve(str, onError) { + onError(`Unresolved plain scalar ${JSON.stringify(str)}`) + return str + } } ] - -json.scalarFallback = str => { - throw new SyntaxError(`Unresolved plain scalar ${JSON.stringify(str)}`) -} diff --git a/src/tags/yaml-1.1/binary.js b/src/tags/yaml-1.1/binary.js index c0b5e197..fcd255e6 100644 --- a/src/tags/yaml-1.1/binary.js +++ b/src/tags/yaml-1.1/binary.js @@ -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' @@ -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(doc, node) + resolve(src, onError) { if (typeof Buffer === 'function') { return Buffer.from(src, 'base64') } else if (typeof atob === 'function') { @@ -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, diff --git a/src/tags/yaml-1.1/index.js b/src/tags/yaml-1.1/index.js index 3f8c6528..75a6e611 100644 --- a/src/tags/yaml-1.1/index.js +++ b/src/tags/yaml-1.1/index.js @@ -16,8 +16,10 @@ const boolStringify = ({ value }) => const intIdentify = value => typeof value === 'bigint' || Number.isInteger(value) -function intResolve(sign, src, radix) { - let str = src.replace(/_/g, '') +function intResolve(str, offset, radix) { + const sign = str[0] + if (sign === '-' || sign === '+') offset += 1 + str = str.substring(offset).replace(/_/g, '') if (intOptions.asBigInt) { switch (radix) { case 2: @@ -76,7 +78,7 @@ export const yaml11 = failsafe.concat( identify: value => typeof value === 'boolean', default: true, tag: 'tag:yaml.org,2002:bool', - test: /^(?:N|n|[Nn]o|NO|[Ff]alse|FALSE|[Oo]ff|OFF)$/i, + test: /^(?:N|n|[Nn]o|NO|[Ff]alse|FALSE|[Oo]ff|OFF)$/, resolve: () => false, options: boolOptions, stringify: boolStringify @@ -86,8 +88,8 @@ export const yaml11 = failsafe.concat( default: true, tag: 'tag:yaml.org,2002:int', format: 'BIN', - test: /^([-+]?)0b([0-1_]+)$/, - resolve: (str, sign, bin) => intResolve(sign, bin, 2), + test: /^[-+]?0b[0-1_]+$/, + resolve: str => intResolve(str, 2, 2), stringify: node => intStringify(node, 2, '0b') }, { @@ -95,16 +97,16 @@ export const yaml11 = failsafe.concat( default: true, tag: 'tag:yaml.org,2002:int', format: 'OCT', - test: /^([-+]?)0([0-7_]+)$/, - resolve: (str, sign, oct) => intResolve(sign, oct, 8), + test: /^[-+]?0[0-7_]+$/, + resolve: str => intResolve(str, 1, 8), stringify: node => intStringify(node, 8, '0') }, { identify: intIdentify, default: true, tag: 'tag:yaml.org,2002:int', - test: /^([-+]?)([0-9][0-9_]*)$/, - resolve: (str, sign, abs) => intResolve(sign, abs, 10), + test: /^[-+]?[0-9][0-9_]*$/, + resolve: str => intResolve(str, 0, 10), stringify: stringifyNumber }, { @@ -112,17 +114,17 @@ export const yaml11 = failsafe.concat( default: true, tag: 'tag:yaml.org,2002:int', format: 'HEX', - test: /^([-+]?)0x([0-9a-fA-F_]+)$/, - resolve: (str, sign, hex) => intResolve(sign, hex, 16), + test: /^[-+]?0x[0-9a-fA-F_]+$/, + resolve: str => intResolve(str, 2, 16), stringify: node => intStringify(node, 16, '0x') }, { identify: value => typeof value === 'number', default: true, tag: 'tag:yaml.org,2002:float', - test: /^(?:[-+]?\.inf|(\.nan))$/i, - resolve: (str, nan) => - nan + test: /^[-+]?\.(?:inf|Inf|INF|nan|NaN|NAN)$/, + resolve: str => + str.slice(-3).toLowerCase() === 'nan' ? NaN : str[0] === '-' ? Number.NEGATIVE_INFINITY @@ -134,7 +136,7 @@ export const yaml11 = failsafe.concat( default: true, tag: 'tag:yaml.org,2002:float', format: 'EXP', - test: /^[-+]?([0-9][0-9_]*)?(\.[0-9_]*)?[eE][-+]?[0-9]+$/, + test: /^[-+]?(?:[0-9][0-9_]*)?(?:\.[0-9_]*)?[eE][-+]?[0-9]+$/, resolve: str => parseFloat(str.replace(/_/g, '')), stringify: ({ value }) => Number(value).toExponential() }, @@ -142,11 +144,12 @@ export const yaml11 = failsafe.concat( identify: value => typeof value === 'number', default: true, tag: 'tag:yaml.org,2002:float', - test: /^[-+]?(?:[0-9][0-9_]*)?\.([0-9_]*)$/, - resolve(str, frac) { + test: /^[-+]?(?:[0-9][0-9_]*)?\.[0-9_]*$/, + resolve(str) { const node = new Scalar(parseFloat(str.replace(/_/g, ''))) - if (frac) { - const f = frac.replace(/_/g, '') + const dot = str.indexOf('.') + if (dot !== -1) { + const f = str.substring(dot + 1).replace(/_/g, '') if (f[f.length - 1] === '0') node.minFractionDigits = f.length } return node diff --git a/src/tags/yaml-1.1/omap.js b/src/tags/yaml-1.1/omap.js index 97632f60..cb390569 100644 --- a/src/tags/yaml-1.1/omap.js +++ b/src/tags/yaml-1.1/omap.js @@ -1,4 +1,3 @@ -import { YAMLSemanticError } from '../../errors.js' import { Pair } from '../../ast/Pair.js' import { Scalar } from '../../ast/Scalar.js' import { YAMLMap } from '../../ast/YAMLMap.js' @@ -39,14 +38,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) } diff --git a/src/tags/yaml-1.1/pairs.js b/src/tags/yaml-1.1/pairs.js index 50ae6817..eb4dc132 100644 --- a/src/tags/yaml-1.1/pairs.js +++ b/src/tags/yaml-1.1/pairs.js @@ -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 } diff --git a/src/tags/yaml-1.1/set.js b/src/tags/yaml-1.1/set.js index 11ba81d4..f421ad34 100644 --- a/src/tags/yaml-1.1/set.js +++ b/src/tags/yaml-1.1/set.js @@ -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' @@ -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) { diff --git a/src/tags/yaml-1.1/timestamp.js b/src/tags/yaml-1.1/timestamp.js index 59628b2d..e2c5bfc0 100644 --- a/src/tags/yaml-1.1/timestamp.js +++ b/src/tags/yaml-1.1/timestamp.js @@ -1,26 +1,38 @@ +/* global BigInt */ + +import { intOptions } from '../options.js' import { stringifyNumber } from '../../stringify/stringifyNumber.js' -const parseSexagesimal = (sign, parts) => { - const n = parts.split(':').reduce((n, p) => n * 60 + Number(p), 0) - return sign === '-' ? -n : n +const parseSexagesimal = (str, isInt) => { + const sign = str[0] + const parts = sign === '-' || sign === '+' ? str.substring(1) : str + const num = n => (isInt && intOptions.asBigInt ? BigInt(n) : Number(n)) + const res = parts + .replace(/_/g, '') + .split(':') + .reduce((res, p) => res * num(60) + num(p), num(0)) + return sign === '-' ? num(-1) * res : res } // hhhh:mm:ss.sss const stringifySexagesimal = ({ value }) => { - if (isNaN(value) || !isFinite(value)) return stringifyNumber(value) + let num = n => n + if (typeof value === 'bigint') num = n => BigInt(n) + else if (isNaN(value) || !isFinite(value)) return stringifyNumber(value) let sign = '' if (value < 0) { sign = '-' - value = Math.abs(value) + value *= num(-1) } - const parts = [value % 60] // seconds, including ms + const _60 = num(60) + const parts = [value % _60] // seconds, including ms if (value < 60) { parts.unshift(0) // at least one : is required } else { - value = Math.round((value - parts[0]) / 60) - parts.unshift(value % 60) // minutes + value = (value - parts[0]) / _60 + parts.unshift(value % _60) // minutes if (value >= 60) { - value = Math.round((value - parts[0]) / 60) + value = (value - parts[0]) / _60 parts.unshift(value) // hours } } @@ -34,13 +46,12 @@ const stringifySexagesimal = ({ value }) => { } export const intTime = { - identify: value => typeof value === 'number', + identify: value => typeof value === 'bigint' || Number.isInteger(value), default: true, tag: 'tag:yaml.org,2002:int', format: 'TIME', - test: /^([-+]?)([0-9][0-9_]*(?::[0-5]?[0-9])+)$/, - resolve: (str, sign, parts) => - parseSexagesimal(sign, parts.replace(/_/g, '')), + test: /^[-+]?[0-9][0-9_]*(?::[0-5]?[0-9])+$/, + resolve: str => parseSexagesimal(str, true), stringify: stringifySexagesimal } @@ -49,9 +60,8 @@ export const floatTime = { default: true, tag: 'tag:yaml.org,2002:float', format: 'TIME', - test: /^([-+]?)([0-9][0-9_]*(?::[0-5]?[0-9])+\.[0-9_]*)$/, - resolve: (str, sign, parts) => - parseSexagesimal(sign, parts.replace(/_/g, '')), + test: /^[-+]?[0-9][0-9_]*(?::[0-5]?[0-9])+\.[0-9_]*$/, + resolve: str => parseSexagesimal(str, false), stringify: stringifySexagesimal } @@ -63,15 +73,17 @@ export const timestamp = { // may be omitted altogether, resulting in a date format. In such a case, the time part is // assumed to be 00:00:00Z (start of day, UTC). test: RegExp( - '^(?:' + - '([0-9]{4})-([0-9]{1,2})-([0-9]{1,2})' + // YYYY-Mm-Dd - '(?:(?:t|T|[ \\t]+)' + // t | T | whitespace + '^([0-9]{4})-([0-9]{1,2})-([0-9]{1,2})' + // YYYY-Mm-Dd + '(?:' + // time is optional + '(?:t|T|[ \\t]+)' + // t | T | whitespace '([0-9]{1,2}):([0-9]{1,2}):([0-9]{1,2}(\\.[0-9]+)?)' + // Hh:Mm:Ss(.ss)? '(?:[ \\t]*(Z|[-+][012]?[0-9](?::[0-9]{2})?))?' + // Z | +5 | -03:30 - ')?' + - ')$' + ')?$' ), - resolve: (str, year, month, day, hour, minute, second, millisec, tz) => { + resolve(str) { + let [, year, month, day, hour, minute, second, millisec, tz] = str.match( + timestamp.test + ) if (millisec) millisec = (millisec + '00').substr(1, 3) let date = Date.UTC( year, @@ -83,7 +95,7 @@ export const timestamp = { millisec || 0 ) if (tz && tz !== 'Z') { - let d = parseSexagesimal(tz[0], tz.slice(1)) + let d = parseSexagesimal(tz, false) if (Math.abs(d) < 30) d *= 60 date -= 60000 * d } diff --git a/src/util.js b/src/util.js index 4cc92844..71aab825 100644 --- a/src/util.js +++ b/src/util.js @@ -1,6 +1,4 @@ export { findPair, toJS } from './ast/index.js' -export { resolveMap as parseMap } from './resolve/resolveMap.js' -export { resolveSeq as parseSeq } from './resolve/resolveSeq.js' export { stringifyNumber } from './stringify/stringifyNumber.js' export { stringifyString } from './stringify/stringifyString.js' diff --git a/tests/doc/YAML-1.2.spec.js b/tests/doc/YAML-1.2.spec.js index e38255df..5fd2ccd3 100644 --- a/tests/doc/YAML-1.2.spec.js +++ b/tests/doc/YAML-1.2.spec.js @@ -515,10 +515,10 @@ application specific tag: !something | ], warnings: [ [ - 'The tag tag:clarkevans.com,2002:shape is unavailable, falling back to tag:yaml.org,2002:seq', 'The tag tag:clarkevans.com,2002:circle is unavailable, falling back to tag:yaml.org,2002:map', 'The tag tag:clarkevans.com,2002:line is unavailable, falling back to tag:yaml.org,2002:map', - 'The tag tag:clarkevans.com,2002:label is unavailable, falling back to tag:yaml.org,2002:map' + 'The tag tag:clarkevans.com,2002:label is unavailable, falling back to tag:yaml.org,2002:map', + 'The tag tag:clarkevans.com,2002:shape is unavailable, falling back to tag:yaml.org,2002:seq' ] ] }, @@ -730,10 +730,7 @@ alias: *anchor`, ['The tag !local is unavailable, falling back to tag:yaml.org,2002:str'] ], special: src => { - const tag = { - tag: '!local', - resolve: (doc, node) => 'local:' + node.strValue - } + const tag = { tag: '!local', resolve: str => `local:${str}` } const res = YAML.parse(src, { customTags: [tag] }) expect(res).toMatchObject({ anchored: 'local:value', @@ -1096,7 +1093,7 @@ bar`, special: src => { const tag = { tag: 'tag:example.com,2000:app/foo', - resolve: (doc, node) => 'foo' + node.strValue + resolve: str => `foo${str}` } const res = YAML.parse(src, { customTags: [tag] }) expect(res).toBe('foobar') @@ -1121,10 +1118,7 @@ bar`, ] ], special: src => { - const tag = { - tag: '!my-light', - resolve: (doc, node) => 'light:' + node.strValue - } + const tag = { tag: '!my-light', resolve: str => `light:${str}` } const docs = YAML.parseAllDocuments(src, { customTags: [tag] }) expect(docs.map(d => d.toJS())).toMatchObject([ 'light:fluorescent', @@ -1146,7 +1140,7 @@ bar`, special: src => { const tag = { tag: 'tag:example.com,2000:app/foo', - resolve: (doc, node) => 'foo' + node.strValue + resolve: str => `foo${str}` } const res = YAML.parse(src, { customTags: [tag] }) expect(res).toMatchObject(['foobar']) @@ -1169,10 +1163,7 @@ bar`, ['The tag !bar is unavailable, falling back to tag:yaml.org,2002:str'] ], special: src => { - const tag = { - tag: '!bar', - resolve: (doc, node) => 'bar' + node.strValue - } + const tag = { tag: '!bar', resolve: str => `bar${str}` } const res = YAML.parse(src, { customTags: [tag] }) expect(res).toMatchObject({ foo: 'barbaz' }) } @@ -1203,13 +1194,10 @@ bar`, ], special: src => { const customTags = [ - { - tag: '!local', - resolve: (doc, node) => 'local:' + node.strValue - }, + { tag: '!local', resolve: str => `local:${str}` }, { tag: 'tag:example.com,2000:app/tag!', - resolve: (doc, node) => 'tag!' + node.strValue + resolve: str => `tag!${str}` } ] const res = YAML.parse(src, { customTags }) @@ -1777,10 +1765,7 @@ folded: ['The tag !foo is unavailable, falling back to tag:yaml.org,2002:str'] ], special: src => { - const tag = { - tag: '!foo', - resolve: (doc, node) => 'foo' + node.strValue - } + const tag = { tag: '!foo', resolve: str => `foo${str}` } const res = YAML.parse(src, { customTags: [tag] }) expect(res).toMatchObject({ literal: 'value\n', folded: 'foovalue\n' }) } diff --git a/tests/doc/stringify.js b/tests/doc/stringify.js index 9e6fd9b0..b5d93626 100644 --- a/tests/doc/stringify.js +++ b/tests/doc/stringify.js @@ -347,8 +347,8 @@ describe('eemeli/yaml#80: custom tags', () => { const regexp = { identify: value => value instanceof RegExp, tag: '!re', - resolve(doc, cst) { - const match = cst.strValue.match(/^\/([\s\S]+)\/([gimuy]*)$/) + resolve(str) { + const match = str.match(/^\/([\s\S]+)\/([gimuy]*)$/) return new RegExp(match[1], match[2]) } } @@ -356,7 +356,7 @@ describe('eemeli/yaml#80: custom tags', () => { const sharedSymbol = { identify: value => value.constructor === Symbol, tag: '!symbol/shared', - resolve: (doc, cst) => Symbol.for(cst.strValue), + resolve: (str) => Symbol.for(str), stringify(item, ctx, onComment, onChompKeep) { const key = Symbol.keyFor(item.value) if (key === undefined) diff --git a/tests/doc/types.js b/tests/doc/types.js index fac17637..8e81ba79 100644 --- a/tests/doc/types.js +++ b/tests/doc/types.js @@ -104,8 +104,7 @@ describe('json schema', () => { "canonical": null "english": null ? null -: "null key"\n` - ) +: "null key"\n`) }) }) @@ -356,6 +355,40 @@ binary: 0b10100111010010101110 sexagesimal: 190:20:30\n`) }) + test('!!int, asBigInt', () => { + const src = `%YAML 1.1 +--- +canonical: 685230 +decimal: +685_230 +octal: 02472256 +hexadecimal: 0x_0A_74_AE +binary: 0b1010_0111_0100_1010_1110 +sexagesimal: 190:20:30` + + try { + YAML.scalarOptions.int.asBigInt = true + const doc = YAML.parseDocument(src) + expect(doc.toJS()).toMatchObject({ + canonical: 685230n, + decimal: 685230n, + octal: 685230n, + hexadecimal: 685230n, + binary: 685230n, + sexagesimal: 685230n + }) + expect(String(doc)).toBe(`%YAML 1.1 +--- +canonical: 685230 +decimal: 685230 +octal: 02472256 +hexadecimal: 0xa74ae +binary: 0b10100111010010101110 +sexagesimal: 190:20:30\n`) + } finally { + YAML.scalarOptions.int.asBigInt = false + } + }) + test('!!null', () => { const src = `%YAML 1.1 --- @@ -476,7 +509,7 @@ date (00:00:00Z): 2002-12-14\n`) expect(doc.errors).toMatchObject([ { name: 'YAMLSemanticError', - message: 'Ordered maps must not include duplicate keys' + message: 'Ordered maps must not include duplicate keys: b' } ]) }) diff --git a/types.d.ts b/types.d.ts index d20baf76..0d7737f3 100644 --- a/types.d.ts +++ b/types.d.ts @@ -118,6 +118,14 @@ export namespace Schema { * Used by some tags to configure their stringification, where applicable. */ options?: object + /** + * Turns a value into an AST node. + * If returning a non-`Node` value, the output will be wrapped as a `Scalar`. + */ + resolve( + value: string | YAMLMap | YAMLSeq, + onError: (message: string) => void + ): Node | any /** * Optional function stringifying the AST node in the current context. If your * data includes a suitable `.toString()` method, you can probably leave this @@ -145,11 +153,7 @@ export namespace Schema { } interface CustomTag extends BaseTag { - /** - * Turns a CST node into an AST node. If returning a non-`Node` value, the - * output will be wrapped as a `Scalar`. - */ - resolve(doc: Document, cstNode: CST.Node): Node | any + default?: false } interface DefaultTag extends BaseTag { @@ -159,10 +163,6 @@ export namespace Schema { * use this, even if you first think you do. */ default: true - /** - * Alternative form used by default tags; called with `test` match results. - */ - resolve(...match: string[]): Node | any /** * Together with `default` allows for values to be stringified without an * explicit tag and detected using a regular expression. For most cases, it's diff --git a/util.d.ts b/util.d.ts index 5364c26f..dace24ec 100644 --- a/util.d.ts +++ b/util.d.ts @@ -1,14 +1,8 @@ -import { Document } from './index' import { CST } from './parse-cst' -import { AST, Pair, Scalar, Schema } from './types' +import { Pair, Scalar, Schema } from './types' export function findPair(items: any[], key: Scalar | any): Pair | undefined -export function parseMap(doc: Document, cst: CST.Map): AST.BlockMap -export function parseMap(doc: Document, cst: CST.FlowMap): AST.FlowMap -export function parseSeq(doc: Document, cst: CST.Seq): AST.BlockSeq -export function parseSeq(doc: Document, cst: CST.FlowSeq): AST.FlowSeq - export function stringifyNumber(item: Scalar): string export function stringifyString( item: Scalar, diff --git a/util.js b/util.js index 06dd2c99..d9472bcb 100644 --- a/util.js +++ b/util.js @@ -2,8 +2,6 @@ const util = require('./dist/util') exports.findPair = util.findPair exports.toJSON = util.toJSON -exports.parseMap = util.parseMap -exports.parseSeq = util.parseSeq exports.stringifyNumber = util.stringifyNumber exports.stringifyString = util.stringifyString diff --git a/util.mjs b/util.mjs index 89e654ab..9df8eaa7 100644 --- a/util.mjs +++ b/util.mjs @@ -3,9 +3,6 @@ import util from './dist/util.js' export const findPair = util.findPair export const toJSON = util.toJSON -export const parseMap = util.parseMap -export const parseSeq = util.parseSeq - export const stringifyNumber = util.stringifyNumber export const stringifyString = util.stringifyString