stringifyString.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339
  1. 'use strict';
  2. var Scalar = require('../nodes/Scalar.js');
  3. var foldFlowLines = require('./foldFlowLines.js');
  4. const getFoldOptions = (ctx, isBlock) => ({
  5. indentAtStart: isBlock ? ctx.indent.length : ctx.indentAtStart,
  6. lineWidth: ctx.options.lineWidth,
  7. minContentWidth: ctx.options.minContentWidth
  8. });
  9. // Also checks for lines starting with %, as parsing the output as YAML 1.1 will
  10. // presume that's starting a new document.
  11. const containsDocumentMarker = (str) => /^(%|---|\.\.\.)/m.test(str);
  12. function lineLengthOverLimit(str, lineWidth, indentLength) {
  13. if (!lineWidth || lineWidth < 0)
  14. return false;
  15. const limit = lineWidth - indentLength;
  16. const strLen = str.length;
  17. if (strLen <= limit)
  18. return false;
  19. for (let i = 0, start = 0; i < strLen; ++i) {
  20. if (str[i] === '\n') {
  21. if (i - start > limit)
  22. return true;
  23. start = i + 1;
  24. if (strLen - start <= limit)
  25. return false;
  26. }
  27. }
  28. return true;
  29. }
  30. function doubleQuotedString(value, ctx) {
  31. const json = JSON.stringify(value);
  32. if (ctx.options.doubleQuotedAsJSON)
  33. return json;
  34. const { implicitKey } = ctx;
  35. const minMultiLineLength = ctx.options.doubleQuotedMinMultiLineLength;
  36. const indent = ctx.indent || (containsDocumentMarker(value) ? ' ' : '');
  37. let str = '';
  38. let start = 0;
  39. for (let i = 0, ch = json[i]; ch; ch = json[++i]) {
  40. if (ch === ' ' && json[i + 1] === '\\' && json[i + 2] === 'n') {
  41. // space before newline needs to be escaped to not be folded
  42. str += json.slice(start, i) + '\\ ';
  43. i += 1;
  44. start = i;
  45. ch = '\\';
  46. }
  47. if (ch === '\\')
  48. switch (json[i + 1]) {
  49. case 'u':
  50. {
  51. str += json.slice(start, i);
  52. const code = json.substr(i + 2, 4);
  53. switch (code) {
  54. case '0000':
  55. str += '\\0';
  56. break;
  57. case '0007':
  58. str += '\\a';
  59. break;
  60. case '000b':
  61. str += '\\v';
  62. break;
  63. case '001b':
  64. str += '\\e';
  65. break;
  66. case '0085':
  67. str += '\\N';
  68. break;
  69. case '00a0':
  70. str += '\\_';
  71. break;
  72. case '2028':
  73. str += '\\L';
  74. break;
  75. case '2029':
  76. str += '\\P';
  77. break;
  78. default:
  79. if (code.substr(0, 2) === '00')
  80. str += '\\x' + code.substr(2);
  81. else
  82. str += json.substr(i, 6);
  83. }
  84. i += 5;
  85. start = i + 1;
  86. }
  87. break;
  88. case 'n':
  89. if (implicitKey ||
  90. json[i + 2] === '"' ||
  91. json.length < minMultiLineLength) {
  92. i += 1;
  93. }
  94. else {
  95. // folding will eat first newline
  96. str += json.slice(start, i) + '\n\n';
  97. while (json[i + 2] === '\\' &&
  98. json[i + 3] === 'n' &&
  99. json[i + 4] !== '"') {
  100. str += '\n';
  101. i += 2;
  102. }
  103. str += indent;
  104. // space after newline needs to be escaped to not be folded
  105. if (json[i + 2] === ' ')
  106. str += '\\';
  107. i += 1;
  108. start = i + 1;
  109. }
  110. break;
  111. default:
  112. i += 1;
  113. }
  114. }
  115. str = start ? str + json.slice(start) : json;
  116. return implicitKey
  117. ? str
  118. : foldFlowLines.foldFlowLines(str, indent, foldFlowLines.FOLD_QUOTED, getFoldOptions(ctx, false));
  119. }
  120. function singleQuotedString(value, ctx) {
  121. if (ctx.options.singleQuote === false ||
  122. (ctx.implicitKey && value.includes('\n')) ||
  123. /[ \t]\n|\n[ \t]/.test(value) // single quoted string can't have leading or trailing whitespace around newline
  124. )
  125. return doubleQuotedString(value, ctx);
  126. const indent = ctx.indent || (containsDocumentMarker(value) ? ' ' : '');
  127. const res = "'" + value.replace(/'/g, "''").replace(/\n+/g, `$&\n${indent}`) + "'";
  128. return ctx.implicitKey
  129. ? res
  130. : foldFlowLines.foldFlowLines(res, indent, foldFlowLines.FOLD_FLOW, getFoldOptions(ctx, false));
  131. }
  132. function quotedString(value, ctx) {
  133. const { singleQuote } = ctx.options;
  134. let qs;
  135. if (singleQuote === false)
  136. qs = doubleQuotedString;
  137. else {
  138. const hasDouble = value.includes('"');
  139. const hasSingle = value.includes("'");
  140. if (hasDouble && !hasSingle)
  141. qs = singleQuotedString;
  142. else if (hasSingle && !hasDouble)
  143. qs = doubleQuotedString;
  144. else
  145. qs = singleQuote ? singleQuotedString : doubleQuotedString;
  146. }
  147. return qs(value, ctx);
  148. }
  149. // The negative lookbehind avoids a polynomial search,
  150. // but isn't supported yet on Safari: https://caniuse.com/js-regexp-lookbehind
  151. let blockEndNewlines;
  152. try {
  153. blockEndNewlines = new RegExp('(^|(?<!\n))\n+(?!\n|$)', 'g');
  154. }
  155. catch {
  156. blockEndNewlines = /\n+(?!\n|$)/g;
  157. }
  158. function blockString({ comment, type, value }, ctx, onComment, onChompKeep) {
  159. const { blockQuote, commentString, lineWidth } = ctx.options;
  160. // 1. Block can't end in whitespace unless the last line is non-empty.
  161. // 2. Strings consisting of only whitespace are best rendered explicitly.
  162. if (!blockQuote || /\n[\t ]+$/.test(value) || /^\s*$/.test(value)) {
  163. return quotedString(value, ctx);
  164. }
  165. const indent = ctx.indent ||
  166. (ctx.forceBlockIndent || containsDocumentMarker(value) ? ' ' : '');
  167. const literal = blockQuote === 'literal'
  168. ? true
  169. : blockQuote === 'folded' || type === Scalar.Scalar.BLOCK_FOLDED
  170. ? false
  171. : type === Scalar.Scalar.BLOCK_LITERAL
  172. ? true
  173. : !lineLengthOverLimit(value, lineWidth, indent.length);
  174. if (!value)
  175. return literal ? '|\n' : '>\n';
  176. // determine chomping from whitespace at value end
  177. let chomp;
  178. let endStart;
  179. for (endStart = value.length; endStart > 0; --endStart) {
  180. const ch = value[endStart - 1];
  181. if (ch !== '\n' && ch !== '\t' && ch !== ' ')
  182. break;
  183. }
  184. let end = value.substring(endStart);
  185. const endNlPos = end.indexOf('\n');
  186. if (endNlPos === -1) {
  187. chomp = '-'; // strip
  188. }
  189. else if (value === end || endNlPos !== end.length - 1) {
  190. chomp = '+'; // keep
  191. if (onChompKeep)
  192. onChompKeep();
  193. }
  194. else {
  195. chomp = ''; // clip
  196. }
  197. if (end) {
  198. value = value.slice(0, -end.length);
  199. if (end[end.length - 1] === '\n')
  200. end = end.slice(0, -1);
  201. end = end.replace(blockEndNewlines, `$&${indent}`);
  202. }
  203. // determine indent indicator from whitespace at value start
  204. let startWithSpace = false;
  205. let startEnd;
  206. let startNlPos = -1;
  207. for (startEnd = 0; startEnd < value.length; ++startEnd) {
  208. const ch = value[startEnd];
  209. if (ch === ' ')
  210. startWithSpace = true;
  211. else if (ch === '\n')
  212. startNlPos = startEnd;
  213. else
  214. break;
  215. }
  216. let start = value.substring(0, startNlPos < startEnd ? startNlPos + 1 : startEnd);
  217. if (start) {
  218. value = value.substring(start.length);
  219. start = start.replace(/\n+/g, `$&${indent}`);
  220. }
  221. const indentSize = indent ? '2' : '1'; // root is at -1
  222. // Leading | or > is added later
  223. let header = (startWithSpace ? indentSize : '') + chomp;
  224. if (comment) {
  225. header += ' ' + commentString(comment.replace(/ ?[\r\n]+/g, ' '));
  226. if (onComment)
  227. onComment();
  228. }
  229. if (!literal) {
  230. const foldedValue = value
  231. .replace(/\n+/g, '\n$&')
  232. .replace(/(?:^|\n)([\t ].*)(?:([\n\t ]*)\n(?![\n\t ]))?/g, '$1$2') // more-indented lines aren't folded
  233. // ^ more-ind. ^ empty ^ capture next empty lines only at end of indent
  234. .replace(/\n+/g, `$&${indent}`);
  235. let literalFallback = false;
  236. const foldOptions = getFoldOptions(ctx, true);
  237. if (blockQuote !== 'folded' && type !== Scalar.Scalar.BLOCK_FOLDED) {
  238. foldOptions.onOverflow = () => {
  239. literalFallback = true;
  240. };
  241. }
  242. const body = foldFlowLines.foldFlowLines(`${start}${foldedValue}${end}`, indent, foldFlowLines.FOLD_BLOCK, foldOptions);
  243. if (!literalFallback)
  244. return `>${header}\n${indent}${body}`;
  245. }
  246. value = value.replace(/\n+/g, `$&${indent}`);
  247. return `|${header}\n${indent}${start}${value}${end}`;
  248. }
  249. function plainString(item, ctx, onComment, onChompKeep) {
  250. const { type, value } = item;
  251. const { actualString, implicitKey, indent, indentStep, inFlow } = ctx;
  252. if ((implicitKey && value.includes('\n')) ||
  253. (inFlow && /[[\]{},]/.test(value))) {
  254. return quotedString(value, ctx);
  255. }
  256. if (!value ||
  257. /^[\n\t ,[\]{}#&*!|>'"%@`]|^[?-]$|^[?-][ \t]|[\n:][ \t]|[ \t]\n|[\n\t ]#|[\n\t :]$/.test(value)) {
  258. // not allowed:
  259. // - empty string, '-' or '?'
  260. // - start with an indicator character (except [?:-]) or /[?-] /
  261. // - '\n ', ': ' or ' \n' anywhere
  262. // - '#' not preceded by a non-space char
  263. // - end with ' ' or ':'
  264. return implicitKey || inFlow || !value.includes('\n')
  265. ? quotedString(value, ctx)
  266. : blockString(item, ctx, onComment, onChompKeep);
  267. }
  268. if (!implicitKey &&
  269. !inFlow &&
  270. type !== Scalar.Scalar.PLAIN &&
  271. value.includes('\n')) {
  272. // Where allowed & type not set explicitly, prefer block style for multiline strings
  273. return blockString(item, ctx, onComment, onChompKeep);
  274. }
  275. if (containsDocumentMarker(value)) {
  276. if (indent === '') {
  277. ctx.forceBlockIndent = true;
  278. return blockString(item, ctx, onComment, onChompKeep);
  279. }
  280. else if (implicitKey && indent === indentStep) {
  281. return quotedString(value, ctx);
  282. }
  283. }
  284. const str = value.replace(/\n+/g, `$&\n${indent}`);
  285. // Verify that output will be parsed as a string, as e.g. plain numbers and
  286. // booleans get parsed with those types in v1.2 (e.g. '42', 'true' & '0.9e-3'),
  287. // and others in v1.1.
  288. if (actualString) {
  289. const test = (tag) => tag.default && tag.tag !== 'tag:yaml.org,2002:str' && tag.test?.test(str);
  290. const { compat, tags } = ctx.doc.schema;
  291. if (tags.some(test) || compat?.some(test))
  292. return quotedString(value, ctx);
  293. }
  294. return implicitKey
  295. ? str
  296. : foldFlowLines.foldFlowLines(str, indent, foldFlowLines.FOLD_FLOW, getFoldOptions(ctx, false));
  297. }
  298. function stringifyString(item, ctx, onComment, onChompKeep) {
  299. const { implicitKey, inFlow } = ctx;
  300. const ss = typeof item.value === 'string'
  301. ? item
  302. : Object.assign({}, item, { value: String(item.value) });
  303. let { type } = item;
  304. if (type !== Scalar.Scalar.QUOTE_DOUBLE) {
  305. // force double quotes on control characters & unpaired surrogates
  306. if (/[\x00-\x08\x0b-\x1f\x7f-\x9f\u{D800}-\u{DFFF}]/u.test(ss.value))
  307. type = Scalar.Scalar.QUOTE_DOUBLE;
  308. }
  309. const _stringify = (_type) => {
  310. switch (_type) {
  311. case Scalar.Scalar.BLOCK_FOLDED:
  312. case Scalar.Scalar.BLOCK_LITERAL:
  313. return implicitKey || inFlow
  314. ? quotedString(ss.value, ctx) // blocks are not valid inside flow containers
  315. : blockString(ss, ctx, onComment, onChompKeep);
  316. case Scalar.Scalar.QUOTE_DOUBLE:
  317. return doubleQuotedString(ss.value, ctx);
  318. case Scalar.Scalar.QUOTE_SINGLE:
  319. return singleQuotedString(ss.value, ctx);
  320. case Scalar.Scalar.PLAIN:
  321. return plainString(ss, ctx, onComment, onChompKeep);
  322. default:
  323. return null;
  324. }
  325. };
  326. let res = _stringify(type);
  327. if (res === null) {
  328. const { defaultKeyType, defaultStringType } = ctx.options;
  329. const t = (implicitKey && defaultKeyType) || defaultStringType;
  330. res = _stringify(t);
  331. if (res === null)
  332. throw new Error(`Unsupported default string type ${t}`);
  333. }
  334. return res;
  335. }
  336. exports.stringifyString = stringifyString;