stringifyString.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  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. let header = (literal ? '|' : '>') + (startWithSpace ? indentSize : '') + chomp;
  223. if (comment) {
  224. header += ' ' + commentString(comment.replace(/ ?[\r\n]+/g, ' '));
  225. if (onComment)
  226. onComment();
  227. }
  228. if (literal) {
  229. value = value.replace(/\n+/g, `$&${indent}`);
  230. return `${header}\n${indent}${start}${value}${end}`;
  231. }
  232. value = value
  233. .replace(/\n+/g, '\n$&')
  234. .replace(/(?:^|\n)([\t ].*)(?:([\n\t ]*)\n(?![\n\t ]))?/g, '$1$2') // more-indented lines aren't folded
  235. // ^ more-ind. ^ empty ^ capture next empty lines only at end of indent
  236. .replace(/\n+/g, `$&${indent}`);
  237. const body = foldFlowLines.foldFlowLines(`${start}${value}${end}`, indent, foldFlowLines.FOLD_BLOCK, getFoldOptions(ctx, true));
  238. return `${header}\n${indent}${body}`;
  239. }
  240. function plainString(item, ctx, onComment, onChompKeep) {
  241. const { type, value } = item;
  242. const { actualString, implicitKey, indent, indentStep, inFlow } = ctx;
  243. if ((implicitKey && value.includes('\n')) ||
  244. (inFlow && /[[\]{},]/.test(value))) {
  245. return quotedString(value, ctx);
  246. }
  247. if (!value ||
  248. /^[\n\t ,[\]{}#&*!|>'"%@`]|^[?-]$|^[?-][ \t]|[\n:][ \t]|[ \t]\n|[\n\t ]#|[\n\t :]$/.test(value)) {
  249. // not allowed:
  250. // - empty string, '-' or '?'
  251. // - start with an indicator character (except [?:-]) or /[?-] /
  252. // - '\n ', ': ' or ' \n' anywhere
  253. // - '#' not preceded by a non-space char
  254. // - end with ' ' or ':'
  255. return implicitKey || inFlow || !value.includes('\n')
  256. ? quotedString(value, ctx)
  257. : blockString(item, ctx, onComment, onChompKeep);
  258. }
  259. if (!implicitKey &&
  260. !inFlow &&
  261. type !== Scalar.Scalar.PLAIN &&
  262. value.includes('\n')) {
  263. // Where allowed & type not set explicitly, prefer block style for multiline strings
  264. return blockString(item, ctx, onComment, onChompKeep);
  265. }
  266. if (containsDocumentMarker(value)) {
  267. if (indent === '') {
  268. ctx.forceBlockIndent = true;
  269. return blockString(item, ctx, onComment, onChompKeep);
  270. }
  271. else if (implicitKey && indent === indentStep) {
  272. return quotedString(value, ctx);
  273. }
  274. }
  275. const str = value.replace(/\n+/g, `$&\n${indent}`);
  276. // Verify that output will be parsed as a string, as e.g. plain numbers and
  277. // booleans get parsed with those types in v1.2 (e.g. '42', 'true' & '0.9e-3'),
  278. // and others in v1.1.
  279. if (actualString) {
  280. const test = (tag) => tag.default && tag.tag !== 'tag:yaml.org,2002:str' && tag.test?.test(str);
  281. const { compat, tags } = ctx.doc.schema;
  282. if (tags.some(test) || compat?.some(test))
  283. return quotedString(value, ctx);
  284. }
  285. return implicitKey
  286. ? str
  287. : foldFlowLines.foldFlowLines(str, indent, foldFlowLines.FOLD_FLOW, getFoldOptions(ctx, false));
  288. }
  289. function stringifyString(item, ctx, onComment, onChompKeep) {
  290. const { implicitKey, inFlow } = ctx;
  291. const ss = typeof item.value === 'string'
  292. ? item
  293. : Object.assign({}, item, { value: String(item.value) });
  294. let { type } = item;
  295. if (type !== Scalar.Scalar.QUOTE_DOUBLE) {
  296. // force double quotes on control characters & unpaired surrogates
  297. if (/[\x00-\x08\x0b-\x1f\x7f-\x9f\u{D800}-\u{DFFF}]/u.test(ss.value))
  298. type = Scalar.Scalar.QUOTE_DOUBLE;
  299. }
  300. const _stringify = (_type) => {
  301. switch (_type) {
  302. case Scalar.Scalar.BLOCK_FOLDED:
  303. case Scalar.Scalar.BLOCK_LITERAL:
  304. return implicitKey || inFlow
  305. ? quotedString(ss.value, ctx) // blocks are not valid inside flow containers
  306. : blockString(ss, ctx, onComment, onChompKeep);
  307. case Scalar.Scalar.QUOTE_DOUBLE:
  308. return doubleQuotedString(ss.value, ctx);
  309. case Scalar.Scalar.QUOTE_SINGLE:
  310. return singleQuotedString(ss.value, ctx);
  311. case Scalar.Scalar.PLAIN:
  312. return plainString(ss, ctx, onComment, onChompKeep);
  313. default:
  314. return null;
  315. }
  316. };
  317. let res = _stringify(type);
  318. if (res === null) {
  319. const { defaultKeyType, defaultStringType } = ctx.options;
  320. const t = (implicitKey && defaultKeyType) || defaultStringType;
  321. res = _stringify(t);
  322. if (res === null)
  323. throw new Error(`Unsupported default string type ${t}`);
  324. }
  325. return res;
  326. }
  327. exports.stringifyString = stringifyString;