composer.js 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. import { Directives } from '../doc/directives.js';
  2. import { Document } from '../doc/Document.js';
  3. import { YAMLWarning, YAMLParseError } from '../errors.js';
  4. import { isCollection, isPair } from '../nodes/identity.js';
  5. import { composeDoc } from './compose-doc.js';
  6. import { resolveEnd } from './resolve-end.js';
  7. function getErrorPos(src) {
  8. if (typeof src === 'number')
  9. return [src, src + 1];
  10. if (Array.isArray(src))
  11. return src.length === 2 ? src : [src[0], src[1]];
  12. const { offset, source } = src;
  13. return [offset, offset + (typeof source === 'string' ? source.length : 1)];
  14. }
  15. function parsePrelude(prelude) {
  16. let comment = '';
  17. let atComment = false;
  18. let afterEmptyLine = false;
  19. for (let i = 0; i < prelude.length; ++i) {
  20. const source = prelude[i];
  21. switch (source[0]) {
  22. case '#':
  23. comment +=
  24. (comment === '' ? '' : afterEmptyLine ? '\n\n' : '\n') +
  25. (source.substring(1) || ' ');
  26. atComment = true;
  27. afterEmptyLine = false;
  28. break;
  29. case '%':
  30. if (prelude[i + 1]?.[0] !== '#')
  31. i += 1;
  32. atComment = false;
  33. break;
  34. default:
  35. // This may be wrong after doc-end, but in that case it doesn't matter
  36. if (!atComment)
  37. afterEmptyLine = true;
  38. atComment = false;
  39. }
  40. }
  41. return { comment, afterEmptyLine };
  42. }
  43. /**
  44. * Compose a stream of CST nodes into a stream of YAML Documents.
  45. *
  46. * ```ts
  47. * import { Composer, Parser } from 'yaml'
  48. *
  49. * const src: string = ...
  50. * const tokens = new Parser().parse(src)
  51. * const docs = new Composer().compose(tokens)
  52. * ```
  53. */
  54. class Composer {
  55. constructor(options = {}) {
  56. this.doc = null;
  57. this.atDirectives = false;
  58. this.prelude = [];
  59. this.errors = [];
  60. this.warnings = [];
  61. this.onError = (source, code, message, warning) => {
  62. const pos = getErrorPos(source);
  63. if (warning)
  64. this.warnings.push(new YAMLWarning(pos, code, message));
  65. else
  66. this.errors.push(new YAMLParseError(pos, code, message));
  67. };
  68. // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
  69. this.directives = new Directives({ version: options.version || '1.2' });
  70. this.options = options;
  71. }
  72. decorate(doc, afterDoc) {
  73. const { comment, afterEmptyLine } = parsePrelude(this.prelude);
  74. //console.log({ dc: doc.comment, prelude, comment })
  75. if (comment) {
  76. const dc = doc.contents;
  77. if (afterDoc) {
  78. doc.comment = doc.comment ? `${doc.comment}\n${comment}` : comment;
  79. }
  80. else if (afterEmptyLine || doc.directives.docStart || !dc) {
  81. doc.commentBefore = comment;
  82. }
  83. else if (isCollection(dc) && !dc.flow && dc.items.length > 0) {
  84. let it = dc.items[0];
  85. if (isPair(it))
  86. it = it.key;
  87. const cb = it.commentBefore;
  88. it.commentBefore = cb ? `${comment}\n${cb}` : comment;
  89. }
  90. else {
  91. const cb = dc.commentBefore;
  92. dc.commentBefore = cb ? `${comment}\n${cb}` : comment;
  93. }
  94. }
  95. if (afterDoc) {
  96. Array.prototype.push.apply(doc.errors, this.errors);
  97. Array.prototype.push.apply(doc.warnings, this.warnings);
  98. }
  99. else {
  100. doc.errors = this.errors;
  101. doc.warnings = this.warnings;
  102. }
  103. this.prelude = [];
  104. this.errors = [];
  105. this.warnings = [];
  106. }
  107. /**
  108. * Current stream status information.
  109. *
  110. * Mostly useful at the end of input for an empty stream.
  111. */
  112. streamInfo() {
  113. return {
  114. comment: parsePrelude(this.prelude).comment,
  115. directives: this.directives,
  116. errors: this.errors,
  117. warnings: this.warnings
  118. };
  119. }
  120. /**
  121. * Compose tokens into documents.
  122. *
  123. * @param forceDoc - If the stream contains no document, still emit a final document including any comments and directives that would be applied to a subsequent document.
  124. * @param endOffset - Should be set if `forceDoc` is also set, to set the document range end and to indicate errors correctly.
  125. */
  126. *compose(tokens, forceDoc = false, endOffset = -1) {
  127. for (const token of tokens)
  128. yield* this.next(token);
  129. yield* this.end(forceDoc, endOffset);
  130. }
  131. /** Advance the composer by one CST token. */
  132. *next(token) {
  133. switch (token.type) {
  134. case 'directive':
  135. this.directives.add(token.source, (offset, message, warning) => {
  136. const pos = getErrorPos(token);
  137. pos[0] += offset;
  138. this.onError(pos, 'BAD_DIRECTIVE', message, warning);
  139. });
  140. this.prelude.push(token.source);
  141. this.atDirectives = true;
  142. break;
  143. case 'document': {
  144. const doc = composeDoc(this.options, this.directives, token, this.onError);
  145. if (this.atDirectives && !doc.directives.docStart)
  146. this.onError(token, 'MISSING_CHAR', 'Missing directives-end/doc-start indicator line');
  147. this.decorate(doc, false);
  148. if (this.doc)
  149. yield this.doc;
  150. this.doc = doc;
  151. this.atDirectives = false;
  152. break;
  153. }
  154. case 'byte-order-mark':
  155. case 'space':
  156. break;
  157. case 'comment':
  158. case 'newline':
  159. this.prelude.push(token.source);
  160. break;
  161. case 'error': {
  162. const msg = token.source
  163. ? `${token.message}: ${JSON.stringify(token.source)}`
  164. : token.message;
  165. const error = new YAMLParseError(getErrorPos(token), 'UNEXPECTED_TOKEN', msg);
  166. if (this.atDirectives || !this.doc)
  167. this.errors.push(error);
  168. else
  169. this.doc.errors.push(error);
  170. break;
  171. }
  172. case 'doc-end': {
  173. if (!this.doc) {
  174. const msg = 'Unexpected doc-end without preceding document';
  175. this.errors.push(new YAMLParseError(getErrorPos(token), 'UNEXPECTED_TOKEN', msg));
  176. break;
  177. }
  178. this.doc.directives.docEnd = true;
  179. const end = resolveEnd(token.end, token.offset + token.source.length, this.doc.options.strict, this.onError);
  180. this.decorate(this.doc, true);
  181. if (end.comment) {
  182. const dc = this.doc.comment;
  183. this.doc.comment = dc ? `${dc}\n${end.comment}` : end.comment;
  184. }
  185. this.doc.range[2] = end.offset;
  186. break;
  187. }
  188. default:
  189. this.errors.push(new YAMLParseError(getErrorPos(token), 'UNEXPECTED_TOKEN', `Unsupported token ${token.type}`));
  190. }
  191. }
  192. /**
  193. * Call at end of input to yield any remaining document.
  194. *
  195. * @param forceDoc - If the stream contains no document, still emit a final document including any comments and directives that would be applied to a subsequent document.
  196. * @param endOffset - Should be set if `forceDoc` is also set, to set the document range end and to indicate errors correctly.
  197. */
  198. *end(forceDoc = false, endOffset = -1) {
  199. if (this.doc) {
  200. this.decorate(this.doc, true);
  201. yield this.doc;
  202. this.doc = null;
  203. }
  204. else if (forceDoc) {
  205. const opts = Object.assign({ _directives: this.directives }, this.options);
  206. const doc = new Document(undefined, opts);
  207. if (this.atDirectives)
  208. this.onError(endOffset, 'MISSING_CHAR', 'Missing directives-end indicator line');
  209. doc.range = [0, endOffset, endOffset];
  210. this.decorate(doc, false);
  211. yield doc;
  212. }
  213. }
  214. }
  215. export { Composer };