/** * @typedef {import('acorn').Comment} Comment * @typedef {import('acorn').Node} AcornNode * @typedef {import('acorn').Options} AcornOptions * @typedef {import('acorn').Token} Token * @typedef {import('estree').Node} EstreeNode * @typedef {import('estree').Program} Program * @typedef {import('micromark-util-types').Chunk} Chunk * @typedef {import('micromark-util-types').Event} Event * @typedef {import('micromark-util-types').Point} MicromarkPoint * @typedef {import('micromark-util-types').TokenType} TokenType * @typedef {import('unist').Point} UnistPoint */ /** * @typedef Acorn * Acorn-like interface. * @property {import('acorn').parse} parse * Parse a program. * @property {import('acorn').parseExpressionAt} parseExpressionAt * Parse an expression. * * @typedef AcornLoc * @property {number} line * @property {number} column * * @typedef AcornErrorFields * @property {number} raisedAt * @property {number} pos * @property {AcornLoc} loc * * @typedef {Error & AcornErrorFields} AcornError * * @typedef Options * Configuration. * @property {Acorn} acorn * Typically `acorn`, object with `parse` and `parseExpressionAt` fields (required). * @property {Array} tokenTypes * Names of (void) tokens to consider as data; `'lineEnding'` is always * included (required). * @property {AcornOptions | null | undefined} [acornOptions] * Configuration for `acorn` (optional). * @property {MicromarkPoint | null | undefined} [start] * Place where events start (optional, required if `allowEmpty`). * @property {string | null | undefined} [prefix=''] * Text to place before events (default: `''`). * @property {string | null | undefined} [suffix=''] * Text to place after events (default: `''`). * @property {boolean | null | undefined} [expression=false] * Whether this is a program or expression (default: `false`). * @property {boolean | null | undefined} [allowEmpty=false] * Whether an empty expression is allowed (programs are always allowed to * be empty) (default: `false`). * * @typedef Result * Result. * @property {Program | undefined} estree * Program. * @property {AcornError | undefined} error * Error if unparseable * @property {boolean} swallow * Whether the error, if there is one, can be swallowed and more JavaScript * could be valid. * * @typedef {[number, MicromarkPoint]} Stop * * @typedef Collection * @property {string} value * @property {Array} stops */ import { visit } from 'estree-util-visit'; import { VFileMessage } from 'vfile-message'; /** * Parse a list of micromark events with acorn. * * @param {Array} events * Events. * @param {Options} options * Configuration (required). * @returns {Result} * Result. */ // eslint-disable-next-line complexity export function eventsToAcorn(events, options) { const prefix = options.prefix || ''; const suffix = options.suffix || ''; const acornOptions = Object.assign({}, options.acornOptions); /** @type {Array} */ const comments = []; /** @type {Array} */ const tokens = []; const onComment = acornOptions.onComment; const onToken = acornOptions.onToken; let swallow = false; /** @type {AcornNode | undefined} */ let estree; /** @type {AcornError | undefined} */ let exception; /** @type {AcornOptions} */ const acornConfig = Object.assign({}, acornOptions, { onComment: comments, preserveParens: true }); if (onToken) { acornConfig.onToken = tokens; } const collection = collect(events, options.tokenTypes); const source = collection.value; const value = prefix + source + suffix; const isEmptyExpression = options.expression && empty(source); if (isEmptyExpression && !options.allowEmpty) { throw new VFileMessage('Unexpected empty expression', { place: parseOffsetToUnistPoint(0), ruleId: 'unexpected-empty-expression', source: 'micromark-extension-mdx-expression' }); } try { estree = options.expression && !isEmptyExpression ? options.acorn.parseExpressionAt(value, 0, acornConfig) : options.acorn.parse(value, acornConfig); } catch (error_) { const error = /** @type {AcornError} */error_; const point = parseOffsetToUnistPoint(error.pos); error.message = String(error.message).replace(/ \(\d+:\d+\)$/, ''); // Always defined in our unist points that come from micromark. error.pos = point.offset; error.loc = { line: point.line, column: point.column - 1 }; exception = error; swallow = error.raisedAt >= prefix.length + source.length || // Broken comments are raised at their start, not their end. error.message === 'Unterminated comment'; } if (estree && options.expression && !isEmptyExpression) { if (empty(value.slice(estree.end, value.length - suffix.length))) { estree = { type: 'Program', start: 0, end: prefix.length + source.length, // @ts-expect-error: It’s good. body: [{ type: 'ExpressionStatement', expression: estree, start: 0, end: prefix.length + source.length }], sourceType: 'module', comments: [] }; } else { const point = parseOffsetToUnistPoint(estree.end); const error = /** @type {AcornError} */ new Error('Unexpected content after expression'); // Always defined in our unist points that come from micromark. error.pos = point.offset; error.loc = { line: point.line, column: point.column - 1 }; exception = error; estree = undefined; } } if (estree) { // @ts-expect-error: acorn *does* allow comments estree.comments = comments; // @ts-expect-error: acorn looks enough like estree. visit(estree, function (esnode, field, index, parents) { let context = /** @type {AcornNode | Array} */ parents[parents.length - 1]; /** @type {number | string | undefined} */ let prop = field; // Remove non-standard `ParenthesizedExpression`. // @ts-expect-error: included in acorn. if (esnode.type === 'ParenthesizedExpression' && context && prop) { /* c8 ignore next 5 */ if (typeof index === 'number') { // @ts-expect-error: indexable. context = context[prop]; prop = index; } // @ts-expect-error: indexable. context[prop] = esnode.expression; } fixPosition(esnode); }); // Comment positions are fixed by `visit` because they’re in the tree. if (Array.isArray(onComment)) { onComment.push(...comments); } else if (typeof onComment === 'function') { for (const comment of comments) { onComment(comment.type === 'Block', comment.value, comment.start, comment.end, comment.loc.start, comment.loc.end); } } for (const token of tokens) { // Ignore tokens that ends in prefix or start in suffix: if (token.end <= prefix.length || token.start - prefix.length >= source.length) { continue; } fixPosition(token); if (Array.isArray(onToken)) { onToken.push(token); } else { // `tokens` are not added if `onToken` is not defined, so it must be a // function. onToken(token); } } } // @ts-expect-error: It’s a program now. return { estree, error: exception, swallow }; /** * Update the position of a node. * * @param {AcornNode | EstreeNode | Token} nodeOrToken * @returns {undefined} */ function fixPosition(nodeOrToken) { const pointStart = parseOffsetToUnistPoint(nodeOrToken.start); const pointEnd = parseOffsetToUnistPoint(nodeOrToken.end); // Always defined in our unist points that come from micromark. nodeOrToken.start = pointStart.offset; nodeOrToken.end = pointEnd.offset; nodeOrToken.loc = { start: { line: pointStart.line, column: pointStart.column - 1, offset: pointStart.offset }, end: { line: pointEnd.line, column: pointEnd.column - 1, offset: pointEnd.offset } }; nodeOrToken.range = [nodeOrToken.start, nodeOrToken.end]; } /** * Turn an arbitrary offset into the parsed value, into a point in the source * value. * * @param {number} acornOffset * @returns {UnistPoint} */ function parseOffsetToUnistPoint(acornOffset) { let sourceOffset = acornOffset - prefix.length; if (sourceOffset < 0) { sourceOffset = 0; } else if (sourceOffset > source.length) { sourceOffset = source.length; } let point = relativeToPoint(collection.stops, sourceOffset); if (!point) { point = { line: options.start.line, column: options.start.column, offset: options.start.offset }; } return point; } } /** * @param {string} value * @returns {boolean} */ function empty(value) { return /^\s*$/.test(value // Multiline comments. .replace(/\/\*[\s\S]*?\*\//g, '') // Line comments. // EOF instead of EOL is specifically not allowed, because that would // mean the closing brace is on the commented-out line .replace(/\/\/[^\r\n]*(\r\n|\n|\r)/g, '')); } // Port from . /** * @param {Array} events * @param {Array} tokenTypes * @returns {Collection} */ function collect(events, tokenTypes) { /** @type {Collection} */ const result = { value: '', stops: [] }; let index = -1; while (++index < events.length) { const event = events[index]; // Assume void. if (event[0] === 'enter') { const type = event[1].type; if (type === "lineEnding" || tokenTypes.includes(type)) { const chunks = event[2].sliceStream(event[1]); // Drop virtual spaces. while (chunks.length > 0 && chunks[0] === -1) { chunks.shift(); } const value = serializeChunks(chunks); result.stops.push([result.value.length, event[1].start]); result.value += value; result.stops.push([result.value.length, event[1].end]); } } } return result; } // Port from . /** * Turn a relative offset into an absolute offset. * * @param {Array} stops * @param {number} relative * @returns {UnistPoint | undefined} */ function relativeToPoint(stops, relative) { let index = 0; while (index < stops.length && stops[index][0] <= relative) { index += 1; } // There are no points: that only occurs if there was an empty string. if (index === 0) { return undefined; } const [stopRelative, stopAbsolute] = stops[index - 1]; const rest = relative - stopRelative; return { line: stopAbsolute.line, column: stopAbsolute.column + rest, offset: stopAbsolute.offset + rest }; } // Copy from // To do: expose that? /** * Get the string value of a slice of chunks. * * @param {Array} chunks * @returns {string} */ function serializeChunks(chunks) { let index = -1; /** @type {Array} */ const result = []; /** @type {boolean | undefined} */ let atTab; while (++index < chunks.length) { const chunk = chunks[index]; /** @type {string} */ let value; if (typeof chunk === 'string') { value = chunk; } else switch (chunk) { case -5: { value = "\r"; break; } case -4: { value = "\n"; break; } case -3: { value = "\r" + "\n"; break; } case -2: { value = "\t"; break; } /* c8 ignore next 6 */ case -1: { if (atTab) continue; value = " "; break; } default: { // Currently only replacement character. // eslint-disable-next-line unicorn/prefer-code-point value = String.fromCharCode(chunk); } } atTab = chunk === -2; result.push(value); } return result.join(''); }