Repository: eemeli/yaml
Pull request #344 (2021-12-27 10:17:37)

Allow for Schema instance as schema option
Previously, `doc.setSchema` always created a new Schema instance based on the given schema identifier. This change allows for an externally defined schema to be used instead, which makes using a different predefined set of default option values easier.

eemeli: Modified the PR a bit, such that the API now becomes:

```ts
type SchemaOptions = { ...
  /** The base schema to use. */
  schema?: string | Schema
}

class Document { ...
  directives?: Directives // previously required

  /**
   * Change the YAML version and schema used by the document.
   * A `null` version disables support for directives, explicit tags, anchors, and aliases.
   * It also requires the `schema` option to be given as a `Schema` instance value.
   *
   * Overrides all previously set schema options.
   */
  setSchema(version: '1.1' | '1.2' | null, options: SchemaOptions = {}): void
}
```



Patch:
diff --git a/package.json b/package.json
index 1a0cea0..5e7da6a 100644
--- a/package.json
+++ b/package.json
@@ -45,6 +45,7 @@
     "prestart": "npm run build:node",
     "start": "node -i -e 'YAML=require(\"./dist/index.js\")'",
     "test": "jest --config config/jest.config.js",
+    "test:all": "npm test && npm run test:types && npm run test:dist && npm run test:dist:types",
     "test:browsers": "cd playground && npm test",
     "test:dist": "npm run build:node && jest --config config/jest.config.js",
     "test:dist:types": "tsc --allowJs --moduleResolution node --noEmit --target es5 dist/index.js",
diff --git a/src/compose/compose-doc.ts b/src/compose/compose-doc.ts
index 9336980..dadc2db 100644
--- a/src/compose/compose-doc.ts
+++ b/src/compose/compose-doc.ts
@@ -24,6 +24,7 @@ export function composeDoc(
   const opts = Object.assign({ directives }, options)
   const doc = new Document(undefined, opts) as Document.Parsed
   const ctx: ComposeContext = {
+    atRoot: true,
     directives: doc.directives,
     options: doc.options,
     schema: doc.schema
diff --git a/src/compose/compose-node.ts b/src/compose/compose-node.ts
index 5985b82..eb7557a 100644
--- a/src/compose/compose-node.ts
+++ b/src/compose/compose-node.ts
@@ -11,6 +11,7 @@ import { resolveEnd } from './resolve-end.js'
 import { emptyScalarPosition } from './util-empty-scalar-position.js'
 
 export interface ComposeContext {
+  atRoot: boolean
   directives: Directives
   options: Readonly<Required<Omit<ParseOptions, 'lineCounter'>>>
   schema: Readonly<Schema>
diff --git a/src/compose/resolve-block-map.ts b/src/compose/resolve-block-map.ts
index 622ad9d..49f25df 100644
--- a/src/compose/resolve-block-map.ts
+++ b/src/compose/resolve-block-map.ts
@@ -19,6 +19,7 @@ export function resolveBlockMap(
 ) {
   const map = new YAMLMap<ParsedNode, ParsedNode>(ctx.schema)
 
+  if (ctx.atRoot) ctx.atRoot = false
   let offset = bm.offset
   for (const collItem of bm.items) {
     const { start, key, sep, value } = collItem
diff --git a/src/compose/resolve-block-seq.ts b/src/compose/resolve-block-seq.ts
index 82b4347..089fa14 100644
--- a/src/compose/resolve-block-seq.ts
+++ b/src/compose/resolve-block-seq.ts
@@ -12,6 +12,8 @@ export function resolveBlockSeq(
   onError: ComposeErrorHandler
 ) {
   const seq = new YAMLSeq(ctx.schema)
+
+  if (ctx.atRoot) ctx.atRoot = false
   let offset = bs.offset
   for (const { start, value } of bs.items) {
     const props = resolveProps(start, {
diff --git a/src/compose/resolve-flow-collection.ts b/src/compose/resolve-flow-collection.ts
index 5d94b73..17db8d0 100644
--- a/src/compose/resolve-flow-collection.ts
+++ b/src/compose/resolve-flow-collection.ts
@@ -26,6 +26,8 @@ export function resolveFlowCollection(
     ? (new YAMLMap(ctx.schema) as YAMLMap.Parsed)
     : (new YAMLSeq(ctx.schema) as YAMLSeq.Parsed)
   coll.flow = true
+  const atRoot = ctx.atRoot
+  if (atRoot) ctx.atRoot = false
 
   let offset = fc.offset + fc.start.source.length
   for (let i = 0; i < fc.items.length; ++i) {
@@ -194,7 +196,6 @@ export function resolveFlowCollection(
   let cePos = offset
   if (ce && ce.source === expectedEnd) cePos = ce.offset + ce.source.length
   else {
-    const atRoot = fc.indent === -1
     const name = fcName[0].toUpperCase() + fcName.substring(1)
     const msg = atRoot
       ? `${name} must end with a ${expectedEnd}`
diff --git a/src/doc/Document.ts b/src/doc/Document.ts
index 0677838..7b18fb9 100644
--- a/src/doc/Document.ts
+++ b/src/doc/Document.ts
@@ -37,6 +37,7 @@ export type Replacer = any[] | ((key: any, value: any) => unknown)
 
 export declare namespace Document {
   interface Parsed<T extends ParsedNode = ParsedNode> extends Document<T> {
+    directives: Directives
     range: Range
   }
 }
@@ -53,7 +54,7 @@ export class Document<T = unknown> {
   /** The document contents. */
   contents: T | null
 
-  directives: Directives
+  directives?: Directives
 
   /** Errors encountered during parsing. */
   errors: YAMLError[] = []
@@ -139,7 +140,7 @@ export class Document<T = unknown> {
     copy.errors = this.errors.slice()
     copy.warnings = this.warnings.slice()
     copy.options = Object.assign({}, this.options)
-    copy.directives = this.directives.clone()
+    if (this.directives) copy.directives = this.directives.clone()
     copy.schema = this.schema.clone()
     copy.contents = isNode(this.contents)
       ? (this.contents.clone(copy.schema) as unknown as T)
@@ -346,32 +347,45 @@ export class Document<T = unknown> {
 
   /**
    * Change the YAML version and schema used by the document.
+   * A `null` version disables support for directives, explicit tags, anchors, and aliases.
+   * It also requires the `schema` option to be given as a `Schema` instance value.
    *
-   * Overrides all previously set schema options
+   * Overrides all previously set schema options.
    */
-  setSchema(version: '1.1' | '1.2', options?: SchemaOptions) {
-    let _options: SchemaOptions
-    switch (String(version)) {
+  setSchema(version: '1.1' | '1.2' | null, options: SchemaOptions = {}) {
+    if (typeof version === 'number') version = String(version) as '1.1' | '1.2'
+
+    let opt: (SchemaOptions & { schema: string }) | null
+    switch (version) {
       case '1.1':
-        this.directives.yaml.version = '1.1'
-        _options = Object.assign(
-          { merge: true, resolveKnownTags: false, schema: 'yaml-1.1' },
-          options
-        )
+        if (this.directives) this.directives.yaml.version = '1.1'
+        else this.directives = new Directives({ version: '1.1' })
+        opt = { merge: true, resolveKnownTags: false, schema: 'yaml-1.1' }
         break
       case '1.2':
-        this.directives.yaml.version = '1.2'
-        _options = Object.assign(
-          { merge: false, resolveKnownTags: true, schema: 'core' },
-          options
-        )
+        if (this.directives) this.directives.yaml.version = '1.2'
+        else this.directives = new Directives({ version: '1.2' })
+        opt = { merge: false, resolveKnownTags: true, schema: 'core' }
+        break
+      case null:
+        if (this.directives) delete this.directives
+        opt = null
         break
       default: {
         const sv = JSON.stringify(version)
-        throw new Error(`Expected '1.1' or '1.2' as version, but found: ${sv}`)
+        throw new Error(
+          `Expected '1.1', '1.2' or null as first argument, but found: ${sv}`
+        )
       }
     }
-    this.schema = new Schema(_options)
+
+    // Not using `instanceof Schema` to allow for duck typing
+    if (options.schema instanceof Object) this.schema = options.schema
+    else if (opt) this.schema = new Schema(Object.assign(opt, options))
+    else
+      throw new Error(
+        `With a null YAML version, the { schema: Schema } option is required`
+      )
   }
 
   /** A plain JavaScript representation of the document `contents`. */
diff --git a/src/options.ts b/src/options.ts
index 16eb638..9242c09 100644
--- a/src/options.ts
+++ b/src/options.ts
@@ -5,6 +5,7 @@ import type { ParsedNode } from './nodes/Node.js'
 import type { Pair } from './nodes/Pair.js'
 import type { Scalar } from './nodes/Scalar.js'
 import type { LineCounter } from './parse/line-counter.js'
+import type { Schema } from './schema/Schema.js'
 import type { Tags } from './schema/tags.js'
 import type { CollectionTag, ScalarTag } from './schema/types.js'
 
@@ -131,7 +132,7 @@ export type SchemaOptions = {
    *
    * Default: `'core'` for YAML 1.2, `'yaml-1.1'` for earlier versions
    */
-  schema?: string
+  schema?: string | Schema
 
   /**
    * When adding to or stringifying a map, sort the entries.
diff --git a/src/parse/parser.ts b/src/parse/parser.ts
index 817ff0c..2258aba 100644
--- a/src/parse/parser.ts
+++ b/src/parse/parser.ts
@@ -302,10 +302,10 @@ export class Parser {
       const top = this.peek(1)
       if (token.type === 'block-scalar') {
         // Block scalars use their parent rather than header indent
-        token.indent = 'indent' in top ? top.indent : -1
+        token.indent = 'indent' in top ? top.indent : 0
       } else if (token.type === 'flow-collection' && top.type === 'document') {
         // Ignore all indent for top-level flow collections
-        token.indent = -1
+        token.indent = 0
       }
       if (token.type === 'flow-collection') fixFlowSeqItems(token)
       switch (top.type) {
diff --git a/src/public-api.ts b/src/public-api.ts
index e6e2206..48a85c4 100644
--- a/src/public-api.ts
+++ b/src/public-api.ts
@@ -21,12 +21,10 @@ export interface EmptyStream
   empty: true
 }
 
-function parseOptions(options: ParseOptions | undefined) {
-  const prettyErrors = !options || options.prettyErrors !== false
+function parseOptions(options: ParseOptions) {
+  const prettyErrors = options.prettyErrors !== false
   const lineCounter =
-    (options && options.lineCounter) ||
-    (prettyErrors && new LineCounter()) ||
-    null
+    options.lineCounter || (prettyErrors && new LineCounter()) || null
   return { lineCounter, prettyErrors }
 }
 
diff --git a/src/schema/Schema.ts b/src/schema/Schema.ts
index 26bba25..85df993 100644
--- a/src/schema/Schema.ts
+++ b/src/schema/Schema.ts
@@ -39,7 +39,7 @@ export class Schema {
       ? getTags(null, compat)
       : null
     this.merge = !!merge
-    this.name = schema || 'core'
+    this.name = (typeof schema === 'string' && schema) || 'core'
     this.knownTags = resolveKnownTags ? coreKnownTags : {}
     this.tags = getTags(customTags, this.name)
     this.toStringOptions = toStringDefaults || null
diff --git a/src/stringify/stringify.ts b/src/stringify/stringify.ts
index 0f204b5..7417d8e 100644
--- a/src/stringify/stringify.ts
+++ b/src/stringify/stringify.ts
@@ -1,5 +1,6 @@
 import { anchorIsValid } from '../doc/anchors.js'
 import type { Document } from '../doc/Document.js'
+import type { Alias } from '../nodes/Alias.js'
 import {
   isAlias,
   isCollection,
@@ -29,6 +30,7 @@ export type StringifyContext = {
   options: Readonly<
     Required<Omit<ToStringOptions, 'collectionStyle' | 'indent'>>
   >
+  resolvedAliases?: Set<Alias>
 }
 
 export function createStringifyContext(
@@ -113,17 +115,15 @@ function stringifyProps(
   tagObj: ScalarTag | CollectionTag,
   { anchors, doc }: StringifyContext
 ) {
+  if (!doc.directives) return ''
   const props = []
   const anchor = (isScalar(node) || isCollection(node)) && node.anchor
   if (anchor && anchorIsValid(anchor)) {
     anchors.add(anchor)
     props.push(`&${anchor}`)
   }
-  if (node.tag) {
-    props.push(doc.directives.tagString(node.tag))
-  } else if (!tagObj.default) {
-    props.push(doc.directives.tagString(tagObj.tag))
-  }
+  const tag = node.tag || (tagObj.default ? null : tagObj.tag)
+  if (tag) props.push(doc.directives.tagString(tag))
   return props.join(' ')
 }
 
@@ -134,7 +134,19 @@ export function stringify(
   onChompKeep?: () => void
 ): string {
   if (isPair(item)) return item.toString(ctx, onComment, onChompKeep)
-  if (isAlias(item)) return item.toString(ctx)
+  if (isAlias(item)) {
+    if (ctx.doc.directives) return item.toString(ctx)
+
+    if (ctx.resolvedAliases?.has(item)) {
+      throw new TypeError(
+        `Cannot stringify circular structure without alias nodes`
+      )
+    } else {
+      if (ctx.resolvedAliases) ctx.resolvedAliases.add(item)
+      else ctx.resolvedAliases = new Set([item])
+      item = item.resolve(ctx.doc)
+    }
+  }
 
   let tagObj: ScalarTag | CollectionTag | undefined = undefined
   const node = isNode(item)
diff --git a/src/stringify/stringifyDocument.ts b/src/stringify/stringifyDocument.ts
index ebcf4e4..abe0358 100644
--- a/src/stringify/stringifyDocument.ts
+++ b/src/stringify/stringifyDocument.ts
@@ -14,7 +14,7 @@ export function stringifyDocument(
 ) {
   const lines: string[] = []
   let hasDirectives = options.directives === true
-  if (options.directives !== false) {
+  if (options.directives !== false && doc.directives) {
     const dir = doc.directives.toString(doc)
     if (dir) {
       lines.push(dir)


Test patch:
diff --git a/tests/doc/types.js b/tests/doc/types.js
index ffb6eb1..69cf9ef 100644
--- a/tests/doc/types.js
+++ b/tests/doc/types.js
@@ -775,7 +775,7 @@ describe('custom tags', () => {
     expect(str).toBe('re: !re /re/g\nsymbol: !symbol/shared foo\n')
   })
 
-  describe('completely custom schema', () => {
+  describe('schema from custom tags', () => {
     test('customTags is required', () => {
       expect(() =>
         YAML.parseDocument('foo', { schema: 'custom-test' })
@@ -836,6 +836,23 @@ describe('custom tags', () => {
       })
       expect(() => String(doc)).toThrow(/Tag not resolved for String value/)
     })
+
+    test('setSchema', () => {
+      const src = '- foo\n'
+      const doc = YAML.parseDocument(src)
+
+      doc.setSchema('1.2', {
+        customTags: [seqTag, stringTag],
+        schema: 'custom-test-1'
+      })
+      expect(doc.toString()).toBe(src)
+
+      doc.setSchema('1.2', {
+        customTags: [stringTag],
+        schema: 'custom-test-2'
+      })
+      expect(() => String(doc)).toThrow(/Tag not resolved for YAMLSeq value/)
+    })
   })
 })
 
@@ -904,4 +921,30 @@ describe('schema changes', () => {
     doc.set('a', false)
     expect(String(doc)).toBe('a: false\n')
   })
+
+  test('custom schema instance', () => {
+    const src = '[ !!bool yes, &foo no, *foo ]'
+    const doc = YAML.parseDocument(src, { version: '1.1' })
+
+    doc.setSchema('1.2', new YAML.Schema({ schema: 'core' }))
+    expect(String(doc)).toBe('[ !!bool true, &foo false, *foo ]\n')
+
+    const yaml11 = new YAML.Schema({ schema: 'yaml-1.1' })
+    doc.setSchema('1.1', Object.assign({}, yaml11))
+    expect(String(doc)).toBe('[ !!bool yes, &foo no, *foo ]\n')
+
+    const schema = new YAML.Schema({ schema: 'core' })
+    doc.setSchema(null, { schema })
+    expect(String(doc)).toBe('[ true, false, false ]\n')
+  })
+
+  test('null version requires Schema instance', () => {
+    const doc = YAML.parseDocument('foo: bar')
+    const msg =
+      'With a null YAML version, the { schema: Schema } option is required'
+    expect(() => doc.setSchema(null)).toThrow(msg)
+    expect(() => doc.setSchema(null, { schema: 'core' })).toThrow(msg)
+    doc.setSchema(null, { schema: doc.schema })
+    expect(doc.toString()).toBe('foo: bar\n')
+  })
 })