import { Scalar } from '../nodes/Scalar.ts' import type { CollectionTag, ScalarTag } from '../schema/types.ts' import { FOLD_BLOCK, FOLD_FLOW, FOLD_QUOTED, foldFlowLines, type FoldOptions } from './foldFlowLines.ts' import type { StringifyContext } from './stringify.ts' interface StringifyScalar { value: string comment?: string | null type?: string } const getFoldOptions = ( ctx: StringifyContext, isBlock: boolean ): FoldOptions => ({ indentAtStart: isBlock ? ctx.indent.length : ctx.indentAtStart, lineWidth: ctx.options.lineWidth, minContentWidth: ctx.options.minContentWidth }) // Also checks for lines starting with %, as parsing the output as YAML 1.1 will // presume that's starting a new document. const containsDocumentMarker = (str: string) => /^(%|---|\.\.\.)/m.test(str) function lineLengthOverLimit( str: string, lineWidth: number, indentLength: number ) { if (!lineWidth || lineWidth < 0) return false const limit = lineWidth - indentLength const strLen = str.length if (strLen <= limit) return false for (let i = 0, start = 0; i < strLen; ++i) { if (str[i] === '\n') { if (i - start > limit) return true start = i + 1 if (strLen - start <= limit) return false } } return true } function doubleQuotedString(value: string, ctx: StringifyContext) { const json = JSON.stringify(value) if (ctx.options.doubleQuotedAsJSON) return json const { implicitKey } = ctx const minMultiLineLength = ctx.options.doubleQuotedMinMultiLineLength const indent = ctx.indent || (containsDocumentMarker(value) ? ' ' : '') let str = '' let start = 0 for (let i = 0, ch = json[i]; ch; ch = json[++i]) { if (ch === ' ' && json[i + 1] === '\\' && json[i + 2] === 'n') { // space before newline needs to be escaped to not be folded str += json.slice(start, i) + '\\ ' i += 1 start = i ch = '\\' } if (ch === '\\') switch (json[i + 1]) { case 'u': { str += json.slice(start, i) const code = json.substr(i + 2, 4) switch (code) { case '0000': str += '\\0' break case '0007': str += '\\a' break case '000b': str += '\\v' break case '001b': str += '\\e' break case '0085': str += '\\N' break case '00a0': str += '\\_' break case '2028': str += '\\L' break case '2029': str += '\\P' break default: if (code.substr(0, 2) === '00') str += '\\x' + code.substr(2) else str += json.substr(i, 6) } i += 5 start = i + 1 } break case 'n': if ( implicitKey || json[i + 2] === '"' || json.length < minMultiLineLength ) { i += 1 } else { // folding will eat first newline str += json.slice(start, i) + '\n\n' while ( json[i + 2] === '\\' && json[i + 3] === 'n' && json[i + 4] !== '"' ) { str += '\n' i += 2 } str += indent // space after newline needs to be escaped to not be folded if (json[i + 2] === ' ') str += '\\' i += 1 start = i + 1 } break default: i += 1 } } str = start ? str + json.slice(start) : json return implicitKey ? str : foldFlowLines(str, indent, FOLD_QUOTED, getFoldOptions(ctx, false)) } function singleQuotedString(value: string, ctx: StringifyContext) { if ( ctx.options.singleQuote === false || (ctx.implicitKey && value.includes('\n')) || /[ \t]\n|\n[ \t]/.test(value) // single quoted string can't have leading or trailing whitespace around newline ) return doubleQuotedString(value, ctx) const indent = ctx.indent || (containsDocumentMarker(value) ? ' ' : '') const res = "'" + value.replace(/'/g, "''").replace(/\n+/g, `$&\n${indent}`) + "'" return ctx.implicitKey ? res : foldFlowLines(res, indent, FOLD_FLOW, getFoldOptions(ctx, false)) } function quotedString(value: string, ctx: StringifyContext) { const { singleQuote } = ctx.options let qs if (singleQuote === false) qs = doubleQuotedString else { const hasDouble = value.includes('"') const hasSingle = value.includes("'") if (hasDouble && !hasSingle) qs = singleQuotedString else if (hasSingle && !hasDouble) qs = doubleQuotedString else qs = singleQuote ? singleQuotedString : doubleQuotedString } return qs(value, ctx) } // The negative lookbehind avoids a polynomial search, // but isn't supported yet on Safari: https://caniuse.com/js-regexp-lookbehind let blockEndNewlines: RegExp try { blockEndNewlines = new RegExp('(^|(? void, onChompKeep?: () => void ) { const { blockQuote, commentString, lineWidth } = ctx.options // 1. Block can't end in whitespace unless the last line is non-empty. // 2. Strings consisting of only whitespace are best rendered explicitly. if (!blockQuote || /\n[\t ]+$/.test(value)) { return quotedString(value, ctx) } const indent = ctx.indent || (ctx.forceBlockIndent || containsDocumentMarker(value) ? ' ' : '') const literal = blockQuote === 'literal' ? true : blockQuote === 'folded' || type === Scalar.BLOCK_FOLDED ? false : type === Scalar.BLOCK_LITERAL ? true : !lineLengthOverLimit(value, lineWidth, indent.length) if (!value) return literal ? '|\n' : '>\n' // determine chomping from whitespace at value end let chomp: '' | '-' | '+' let endStart: number for (endStart = value.length; endStart > 0; --endStart) { const ch = value[endStart - 1] if (ch !== '\n' && ch !== '\t' && ch !== ' ') break } let end = value.substring(endStart) const endNlPos = end.indexOf('\n') if (endNlPos === -1) { chomp = '-' // strip } else if (value === end || endNlPos !== end.length - 1) { chomp = '+' // keep if (onChompKeep) onChompKeep() } else { chomp = '' // clip } if (end) { value = value.slice(0, -end.length) if (end[end.length - 1] === '\n') end = end.slice(0, -1) end = end.replace(blockEndNewlines, `$&${indent}`) } // determine indent indicator from whitespace at value start let startWithSpace = false let startEnd: number let startNlPos = -1 for (startEnd = 0; startEnd < value.length; ++startEnd) { const ch = value[startEnd] if (ch === ' ') startWithSpace = true else if (ch === '\n') startNlPos = startEnd else break } let start = value.substring( 0, startNlPos < startEnd ? startNlPos + 1 : startEnd ) if (start) { value = value.substring(start.length) start = start.replace(/\n+/g, `$&${indent}`) } const indentSize = indent ? '2' : '1' // root is at -1 // Leading | or > is added later let header = (startWithSpace ? indentSize : '') + chomp if (comment) { header += ' ' + commentString(comment.replace(/ ?[\r\n]+/g, ' ')) if (onComment) onComment() } if (!literal) { const foldedValue = value .replace(/\n+/g, '\n$&') .replace(/(?:^|\n)([\t ].*)(?:([\n\t ]*)\n(?![\n\t ]))?/g, '$1$2') // more-indented lines aren't folded // ^ more-ind. ^ empty ^ capture next empty lines only at end of indent .replace(/\n+/g, `$&${indent}`) let literalFallback = false const foldOptions = getFoldOptions(ctx, true) if (blockQuote !== 'folded' && type !== Scalar.BLOCK_FOLDED) { foldOptions.onOverflow = () => { literalFallback = true } } const body = foldFlowLines( `${start}${foldedValue}${end}`, indent, FOLD_BLOCK, foldOptions ) if (!literalFallback) return `>${header}\n${indent}${body}` } value = value.replace(/\n+/g, `$&${indent}`) return `|${header}\n${indent}${start}${value}${end}` } function plainString( item: StringifyScalar, ctx: StringifyContext, onComment?: () => void, onChompKeep?: () => void ) { const { type, value } = item const { actualString, implicitKey, indent, indentStep, inFlow } = ctx if ( (implicitKey && value.includes('\n')) || (inFlow && /[[\]{},]/.test(value)) ) { return quotedString(value, ctx) } if ( /^[\n\t ,[\]{}#&*!|>'"%@`]|^[?-]$|^[?-][ \t]|[\n:][ \t]|[ \t]\n|[\n\t ]#|[\n\t :]$/.test( value ) ) { // not allowed: // - '-' or '?' // - start with an indicator character (except [?:-]) or /[?-] / // - '\n ', ': ' or ' \n' anywhere // - '#' not preceded by a non-space char // - end with ' ' or ':' return implicitKey || inFlow || !value.includes('\n') ? quotedString(value, ctx) : blockString(item, ctx, onComment, onChompKeep) } if ( !implicitKey && !inFlow && type !== Scalar.PLAIN && value.includes('\n') ) { // Where allowed & type not set explicitly, prefer block style for multiline strings return blockString(item, ctx, onComment, onChompKeep) } if (containsDocumentMarker(value)) { if (indent === '') { ctx.forceBlockIndent = true return blockString(item, ctx, onComment, onChompKeep) } else if (implicitKey && indent === indentStep) { return quotedString(value, ctx) } } const str = value.replace(/\n+/g, `$&\n${indent}`) // Verify that output will be parsed as a string, as e.g. plain numbers and // booleans get parsed with those types in v1.2 (e.g. '42', 'true' & '0.9e-3'), // and others in v1.1. if (actualString) { const test = (tag: CollectionTag | ScalarTag) => tag.default && tag.tag !== 'tag:yaml.org,2002:str' && tag.test?.(str) const { compat, tags } = ctx.doc.schema if (tags.some(test) || compat?.some(test)) return quotedString(value, ctx) } return implicitKey ? str : foldFlowLines(str, indent, FOLD_FLOW, getFoldOptions(ctx, false)) } export function stringifyString( item: Scalar | StringifyScalar, ctx: StringifyContext, onComment?: () => void, onChompKeep?: () => void ): string { const { implicitKey, inFlow } = ctx let ss: StringifyScalar if (typeof item.value === 'string') { ss = item as StringifyScalar } else { // Raw JSON values are guaranteed to be valid JSON scalars, // and therefore also valid YAML scalars. if (JSON.isRawJSON?.(item.value)) return item.value.rawJSON ss = { ...item, value: String(item.value) } } let { type } = item if (type !== Scalar.QUOTE_DOUBLE) { // force double quotes on control characters & unpaired surrogates if (/[\x00-\x08\x0b-\x1f\x7f-\x9f\u{D800}-\u{DFFF}]/u.test(ss.value)) type = Scalar.QUOTE_DOUBLE } const _stringify = (_type: string | undefined) => { switch (_type) { case Scalar.BLOCK_FOLDED: case Scalar.BLOCK_LITERAL: return implicitKey || inFlow ? quotedString(ss.value, ctx) // blocks are not valid inside flow containers : blockString(ss, ctx, onComment, onChompKeep) case Scalar.QUOTE_DOUBLE: return doubleQuotedString(ss.value, ctx) case Scalar.QUOTE_SINGLE: return singleQuotedString(ss.value, ctx) case Scalar.PLAIN: return plainString(ss, ctx, onComment, onChompKeep) default: return null } } let res = _stringify(type) if (res === null) { const { defaultKeyType, defaultStringType } = ctx.options const t = (implicitKey && defaultKeyType) || defaultStringType res = _stringify(t) if (res === null) throw new Error(`Unsupported default string type ${t}`) } return res }