stringifyString.js 13 KB

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