features/extendTypes.js

/**
 * @typedef {Object} ExtendTypesCommentWithProperties
 * @property {string}   name        The name of the type.
 * @property {string}   augments    The name of the type it extends.
 * @property {string}   comment     The raw comment.
 * @property {number}   linesCount  How many lines the comment has.
 * @property {string[]} lines       The list of lines from the comment that can be used on
 *                                  the type that it's being extended.
 * @property {number}   usedLines   This counter gets modified during parsing and it
 *                                  represents how many of the `lines` were used: if the
 *                                  type with the intersection already has one line, it's
 *                                  not added, but the class needs to know how many lines
 *                                  it needs to use to replace the comment when removed.
 * @ignore
 */

/**
 * This class allows the use of intersection types in oder to define types extensions by
 * transforming the code in two ways:
 * 1. If one of the types of the intersection uses `augments`/`extends` for the intersection type,
 * all its "lines" are moved to the intersection type and it gets removed.
 * 2. If the first scenario is not possible, it trasforms `&` into `|`. Yes, it becomes a union
 * type, but it's as closer as we can get with pure JSDoc.
 */
class ExtendTypes {
  /**
   * @param {EventEmitter} events       To hook to the necessary events to parse the code.
   * @param {EventNames}   EVENT_NAMES  To get the name of the events the class needs to
   *                                    listen for.
   */
  constructor(events, EVENT_NAMES) {
    /**
     * The list of comments that use intersections on a `typedef` statements.
     *
     * @type {string[]}
     * @access protected
     * @ignore
     */
    this._commentsWithIntersections = [];
    /**
     * A list with the information of comments that extend/agument types with
     * intersections.
     *
     * @type {ExtendTypesCommentWithProperties[]}
     * @access protected
     * @ignore
     */
    this._commentsWithProperties = [];
    // Setup the listeners.
    events.on(EVENT_NAMES.newComment, this._readComment.bind(this));
    events.on(EVENT_NAMES.commentsReady, this._replaceComments.bind(this));
  }
  /**
   * Given a list of types from an instersection definition, this methods tries to find a
   * comment that extends one of those types.
   * If the method finds only one comment for a type, it returns it, otherwise, it returns
   * `null`:
   * the class only supports extending one type.
   *
   * @param {string}   name   The name of the type that uses intersection.
   * @param {string[]} types  The list of types in the intersection.
   * @returns {?ExtendTypesCommentWithProperties}
   * @access protected
   * @ignore
   */
  _getCommentWithProperties(name, types) {
    const comments = types
      .map((type) =>
        this._commentsWithProperties.find(
          (comment) => comment.name === type && comment.augments === name,
        ),
      )
      .filter((comment) => comment);

    return comments.length === 1 ? comments[0] : null;
  }
  /**
   * Parses a JSDock comment block for a type definition that extends a type with
   * intersection.
   *
   * @param {string} comment  The raw comment to parse.
   * @returns {ExtendTypesCommentWithProperties}
   * @access protected
   * @ignore
   */
  _getCommentWithPropertiesInfo(comment) {
    const [, name] = /@typedef\s+\{[^\}]+\}[\s\*]*(.*?)\s/i.exec(comment);
    const [, augments] = /@(?:augments|extends)\s+(.*?)\s/i.exec(comment);
    const allLines = comment.split('\n');
    const linesCount = allLines.length;
    const lines = allLines.filter(
      (line) =>
        line.match(/\w/) && !line.match(/^\s*\*\s*@(?:typedef|augments|extends)\s+/i),
    );
    return {
      name: name.trim(),
      augments: augments.trim(),
      comment,
      linesCount,
      usedLines: 0,
      lines,
    };
  }
  /**
   * This is called every time a new JSDoc comment block is found on a file. It validates
   * if the block uses intersection or if it's a `typedef` that extends another type in
   * order to save it to be parsed later.
   *
   * @param {string} comment  The comment to analyze.
   * @access protected
   * @ignore
   */
  _readComment(comment) {
    if (comment.match(/\*\s*@typedef\s+\{\s*\w+\s*&\s*\w+/i)) {
      this._commentsWithIntersections.push(comment);
    } else if (
      comment.match(/\*\s*@typedef\s+\{/i) &&
      comment.match(/\*\s*@(?:augments|extends)\s+\w+/i)
    ) {
      this._commentsWithProperties.push(this._getCommentWithPropertiesInfo(comment));
    }
  }
  /**
   * Replaces the JSDoc comment blocks for type definitions that extend types with
   * intersections with empty lines.
   *
   * @param {string} source  The code of the file where the comments should be removed.
   * @returns {string}
   * @access protected
   * @ignore
   */
  _removeCommentsWithProperties(source) {
    return this._commentsWithProperties.reduce(
      (acc, comment) =>
        acc.replace(
          comment.comment,
          new Array(comment.linesCount - comment.usedLines).fill('').join('\n'),
        ),
      source,
    );
  }
  /**
   * This is called after all the JSDoc comments block for a file were found and the
   * plugin is ready to make changes.
   * The method first _"fixes"_ the `typedef` statements with intersections and then
   * removes the comments for types that extend another types.
   *
   * @param {string} source  The code of the file being parsed.
   * @returns {string}
   * @access protected
   * @ignore
   */
  _replaceComments(source) {
    let result = this._replaceDefinitions(source);
    result = this._removeCommentsWithProperties(result);
    this._commentsWithIntersections = [];
    this._commentsWithProperties = [];
    return result;
  }
  /**
   * Parses the comments with intersections, validate if there's a type extending them
   * from where they can take "lines", or if they should be transformed into unions.
   *
   * @param {string} source  The code of the file where the comments should be replaced.
   * @returns {string}
   * @access protected
   * @ignore
   */
  _replaceDefinitions(source) {
    return this._commentsWithIntersections.reduce((acc, comment) => {
      // Extract the `typedef` types and name.
      const [typedefLine, rawTypes, name] = /@typedef\s*\{([^\}]+)\}[\s\*]*(.*?)\s/i.exec(
        comment,
      );
      // Transform the types into an array.
      const types = rawTypes.split('&').map((type) => type.trim());

      let replacement;
      // Try to find a type that extends this one.
      const commentWithProps = this._getCommentWithProperties(name, types);
      if (commentWithProps) {
        // If there was a type extending it...
        // Find the "real type" that it's being extended.
        const [baseType] = types.filter((type) => type !== commentWithProps.name);
        // Remove the intersection and leave just the "real type"
        const newTypedefLine = typedefLine.replace(rawTypes, baseType);
        // Replace the `typedef` with the new one, with the "real type".
        const newComment = comment.replace(typedefLine, newTypedefLine);
        // Transform the comment into an array of lines.
        const lines = newComment.split('\n');
        // Remove the line with `*/` so new lines can be added.
        const closingLine = lines.pop();
        /**
         * For each line of the type that extends the definition, check if it doesn't
         * already exists on the comment (this can happen with `access` or `memberof`
         * statements),
         * and if it doesn't, it not only adds it but it also increments the counter that
         * tracks how many lines of the comment are being used.
         *
         * The lines that are moved are counted so the class can later replace the comment
         * with enough empty lines so it won't mess up the line number of the rest of the
         * types.
         * For example: If we had a type with intersection with a single line, a type that
         * extends it with 3 lines lines with `property` and one with `access`
         * (plus the `typedef` and the `extends`/`augments`) and the intersection type
         * already has `access`, the class would replace the comment with `5` empty lines.
         * The comment had a total of 8 lines, 3 `property`, `typedef`,
         * `extends`/`augments`, `access` and the opening and closing lines; but the
         * properties were moved to another type.
         *
         * @ignore
         */
        const info = commentWithProps.lines.reduce(
          (infoAcc, line) => {
            let nextInfoAcc;
            if (infoAcc.lines.includes(line)) {
              nextInfoAcc = infoAcc;
            } else {
              nextInfoAcc = {
                lines: [...infoAcc.lines, line],
                count: infoAcc.count + 1,
              };
            }

            return nextInfoAcc;
          },
          {
            lines,
            count: commentWithProps.usedLines,
          },
        );
        // Add the closing line and put the comment back together.
        info.lines.push(closingLine);
        replacement = info.lines.join('\n');
        // Update the counter with the used lines.
        commentWithProps.usedLines = info.count;
      } else {
        // No comment was found, so transform the intersection into a union.
        const newTypedefLine = typedefLine.replace(rawTypes, types.join('|'));
        replacement = comment.replace(typedefLine, newTypedefLine);
      }

      return acc.replace(comment, replacement);
    }, source);
  }
}

module.exports.ExtendTypes = ExtendTypes;