329 lines
13 KiB
JavaScript
329 lines
13 KiB
JavaScript
|
import { Scalar } from '../nodes/Scalar.js';
|
||
|
import { foldFlowLines, FOLD_QUOTED, FOLD_FLOW, FOLD_BLOCK } from './foldFlowLines.js';
|
||
|
|
||
|
const getFoldOptions = (ctx, isBlock) => ({
|
||
|
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) => /^(%|---|\.\.\.)/m.test(str);
|
||
|
function lineLengthOverLimit(str, lineWidth, indentLength) {
|
||
|
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, ctx) {
|
||
|
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, ctx) {
|
||
|
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, ctx) {
|
||
|
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;
|
||
|
try {
|
||
|
blockEndNewlines = new RegExp('(^|(?<!\n))\n+(?!\n|$)', 'g');
|
||
|
}
|
||
|
catch {
|
||
|
blockEndNewlines = /\n+(?!\n|$)/g;
|
||
|
}
|
||
|
function blockString({ comment, type, value }, ctx, onComment, onChompKeep) {
|
||
|
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) || /^\s*$/.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;
|
||
|
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;
|
||
|
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
|
||
|
let header = (literal ? '|' : '>') + (startWithSpace ? indentSize : '') + chomp;
|
||
|
if (comment) {
|
||
|
header += ' ' + commentString(comment.replace(/ ?[\r\n]+/g, ' '));
|
||
|
if (onComment)
|
||
|
onComment();
|
||
|
}
|
||
|
if (literal) {
|
||
|
value = value.replace(/\n+/g, `$&${indent}`);
|
||
|
return `${header}\n${indent}${start}${value}${end}`;
|
||
|
}
|
||
|
value = 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}`);
|
||
|
const body = foldFlowLines(`${start}${value}${end}`, indent, FOLD_BLOCK, getFoldOptions(ctx, true));
|
||
|
return `${header}\n${indent}${body}`;
|
||
|
}
|
||
|
function plainString(item, ctx, onComment, onChompKeep) {
|
||
|
const { type, value } = item;
|
||
|
const { actualString, implicitKey, indent, indentStep, inFlow } = ctx;
|
||
|
if ((implicitKey && value.includes('\n')) ||
|
||
|
(inFlow && /[[\]{},]/.test(value))) {
|
||
|
return quotedString(value, ctx);
|
||
|
}
|
||
|
if (!value ||
|
||
|
/^[\n\t ,[\]{}#&*!|>'"%@`]|^[?-]$|^[?-][ \t]|[\n:][ \t]|[ \t]\n|[\n\t ]#|[\n\t :]$/.test(value)) {
|
||
|
// not allowed:
|
||
|
// - empty string, '-' 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) => tag.default && tag.tag !== 'tag:yaml.org,2002:str' && tag.test?.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));
|
||
|
}
|
||
|
function stringifyString(item, ctx, onComment, onChompKeep) {
|
||
|
const { implicitKey, inFlow } = ctx;
|
||
|
const ss = typeof item.value === 'string'
|
||
|
? item
|
||
|
: Object.assign({}, 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) => {
|
||
|
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;
|
||
|
}
|
||
|
|
||
|
export { stringifyString };
|