From 01294a0ee872d2acf4e7d6e43ccedb60e2abd8bc Mon Sep 17 00:00:00 2001 From: homer0 Date: Mon, 13 Jul 2020 02:13:41 -0300 Subject: [PATCH] feat: add the extendTypes feature and its tests --- src/features/extendTypes.js | 178 +++++++++++++++++++++++++++++ src/plugin.js | 2 +- tests/features/extendTypes.test.js | 146 +++++++++++++++++++++++ 3 files changed, 325 insertions(+), 1 deletion(-) create mode 100644 src/features/extendTypes.js create mode 100644 tests/features/extendTypes.test.js diff --git a/src/features/extendTypes.js b/src/features/extendTypes.js new file mode 100644 index 0000000..374674a --- /dev/null +++ b/src/features/extendTypes.js @@ -0,0 +1,178 @@ +// @ts-check +/** + * @typedef {Object} ExtendTypesCommentWithProperties + * @property {string} name + * @property {string} augments + * @property {string} comment + * @property {number} linesCount + * @property {number} usedLines + * @property {string[]} lines + */ + +class ExtendTypes { + /** + * @param {EventEmitter} events + * @param {EVENT_NAMES} EVENT_NAMES + */ + constructor(events, EVENT_NAMES) { + /** + * @type {string[]} + * @access protected + * @ignore + */ + this._commentsWithIntersections = []; + /** + * @type {ExtendTypesCommentWithProperties[]} + * @access protected + * @ignore + */ + this._commentsWithProperties = []; + /** + * @typedef {RegExp} + * @access protected + * @ignore + */ + this._intersectionExpression = /\*\s*@typedef\s+\{\s*\w+\s*&\s*\w+/i; + /** + * @typedef {RegExp} + * @access protected + * @ignore + */ + this._typeDefExpression = /\*\s*@typedef\s+\{/i; + /** + * @typedef {RegExp} + * @access protected + * @ignore + */ + this._extendsTypeExpression = /\*\s*@(?:augments|extends)\s+\w+/i; + events.on(EVENT_NAMES.newComment, this._readComment.bind(this)); + events.on(EVENT_NAMES.commentsReady, this._replaceComments.bind(this)); + } + /** + * @param {string} name + * @param {string[]} types + * @returns {?ExtendTypesCommentWithProperties} + */ + _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; + } + /** + * @param {string} comment + * @returns {ExtendTypesCommentWithProperties} + */ + _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, + }; + } + /** + * @param {string} comment + */ + _readComment(comment) { + if (comment.match(this._intersectionExpression)) { + this._commentsWithIntersections.push(comment); + } else if ( + comment.match(this._typeDefExpression) && + comment.match(this._extendsTypeExpression) + ) { + this._commentsWithProperties.push(this._getCommentWithPropertiesInfo(comment)); + } + } + /** + * @param {string} source + * @returns {string} + */ + _removeCommentsWithProperties(source) { + return this._commentsWithProperties.reduce( + (acc, comment) => acc.replace( + comment.comment, + new Array(comment.linesCount - comment.usedLines).fill('').join('\n'), + ), + source, + ); + } + /** + * @param {string} source + * @returns {string} + */ + _replaceComments(source) { + let result = this._replaceDefinitions(source); + result = this._removeCommentsWithProperties(result); + this._commentsWithIntersections = []; + this._commentsWithProperties = []; + return result; + } + /** + * @param {string} source + * @returns {string} + */ + _replaceDefinitions(source) { + return this._commentsWithIntersections.reduce( + (acc, comment) => { + const [typedefLine, rawTypes, name] = /@typedef\s*\{([^\}]+)\}\s*(.*?)\s/i.exec(comment); + const types = rawTypes + .split('&') + .map((type) => type.trim()); + + let replacement; + const commentWithProps = this._getCommentWithProperties(name, types); + if (commentWithProps) { + const [baseType] = types.filter((type) => type !== commentWithProps.name); + const newTypedefLine = typedefLine.replace(rawTypes, baseType); + const newComment = comment.replace(typedefLine, newTypedefLine); + const lines = newComment.split('\n'); + const closingLine = lines.pop(); + 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, + }, + ); + info.lines.push(closingLine); + replacement = info.lines.join('\n'); + commentWithProps.usedLines = info.count; + } else { + const newTypedefLine = typedefLine.replace(rawTypes, types.join('|')); + replacement = comment.replace(typedefLine, newTypedefLine); + } + + return acc.replace(comment, replacement); + }, + source, + ); + } +} + +module.exports.ExtendTypes = ExtendTypes; diff --git a/src/plugin.js b/src/plugin.js index d7e8e54..43b6218 100644 --- a/src/plugin.js +++ b/src/plugin.js @@ -11,7 +11,7 @@ const features = require('./features'); */ const options = { typedefImports: true, - intersections: true, + extendTypes: true, modulesOnMemberOf: true, modulesTypesShortName: true, typeScriptUtilityTypes: true, diff --git a/tests/features/extendTypes.test.js b/tests/features/extendTypes.test.js new file mode 100644 index 0000000..e0674c4 --- /dev/null +++ b/tests/features/extendTypes.test.js @@ -0,0 +1,146 @@ +jest.unmock('../../src/features/extendTypes'); +const { ExtendTypes } = require('../../src/features/extendTypes'); +const { EVENT_NAMES } = require('../../src/constants'); + +describe('features:extendTypes', () => { + it('should register the listeners when instantiated', () => { + // Given + const events = { + on: jest.fn(), + }; + let sut = null; + // When + sut = new ExtendTypes(events, EVENT_NAMES); + // Then + expect(sut).toBeInstanceOf(ExtendTypes); + expect(events.on).toHaveBeenCalledTimes(2); + expect(events.on).toHaveBeenCalledWith(EVENT_NAMES.newComment, expect.any(Function)); + expect(events.on).toHaveBeenCalledWith(EVENT_NAMES.commentsReady, expect.any(Function)); + }); + + it('should ignore comments that don\'t extend types nor use intersection', () => { + // Given + const comment = [ + '/**', + ' * @typedef {Daughter} Rosario', + ' * @typedef {Daughter} Pilar', + ' */', + ].join('\n'); + const source = `${comment} Something`; + const events = { + on: jest.fn(), + }; + let sut = null; + let onComment = null; + let onCommentsReady = null; + let result = null; + // When + sut = new ExtendTypes(events, EVENT_NAMES); + [[, onComment], [, onCommentsReady]] = events.on.mock.calls; + onComment(comment); + result = onCommentsReady(source); + // Then + expect(sut).toBeInstanceOf(ExtendTypes); // to avoid `no-new`. + expect(result).toBe(source); + }); + + it('should transform an itersection into a union', () => { + // Given + const firstType = 'Object'; + const secondType = 'SomeOtherType'; + const definitionName = 'Child'; + const comment = [ + '/**', + ` * @typedef {${firstType} & ${secondType}} ${definitionName}`, + ' */', + ].join('\n'); + const content = ' Some other code'; + const source = `${comment}${content}`; + const events = { + on: jest.fn(), + }; + let sut = null; + let onComment = null; + let onCommentsReady = null; + let result = null; + const newComment = [ + '/**', + ` * @typedef {${firstType}|${secondType}} ${definitionName}`, + ' */', + ].join('\n'); + // When + sut = new ExtendTypes(events, EVENT_NAMES); + [[, onComment], [, onCommentsReady]] = events.on.mock.calls; + onComment(comment); + result = onCommentsReady(source); + // Then + expect(sut).toBeInstanceOf(ExtendTypes); // to avoid `no-new`. + expect(result).toBe(`${newComment}${content}`); + }); + + it('should transform an intersection into an extension', () => { + // Given + const sharedLines = [ + ' * @memberof module:people', + ]; + const extendedProperiesLines = [ + ' * @property {number} name', + ' * @property {number} age', + ' * @property {number} height', + ]; + const extendedType = 'Human'; + const baseType = 'Entity'; + const comment = [ + '/**', + ` * @typedef {${baseType} & ${extendedType}Properties} ${extendedType}`, + ...sharedLines, + ' */', + ].join('\n'); + const propertiesLines = [ + '/**', + ` * @typedef {Object} ${extendedType}Properties`, + ...extendedProperiesLines, + ...sharedLines, + ` * @augments ${extendedType}`, + ' */', + ]; + const propertiesComment = propertiesLines.join('\n'); + const content = 'Some other code'; + const source = [ + comment, + propertiesComment, + content, + ].join('\n'); + const events = { + on: jest.fn(), + }; + let sut = null; + let onComment = null; + let onCommentsReady = null; + let result = null; + const newComment = [ + '/**', + ` * @typedef {${baseType}} ${extendedType}`, + ...sharedLines, + ...extendedProperiesLines, + ' */', + ].join('\n'); + const emptyBlock = new Array(propertiesLines.length - extendedProperiesLines.length) + .fill('') + .join('\n'); + const expectedResult = [ + newComment, + emptyBlock, + content, + ].join('\n'); + // When + sut = new ExtendTypes(events, EVENT_NAMES); + [[, onComment], [, onCommentsReady]] = events.on.mock.calls; + onComment(comment); + onComment(propertiesComment); + result = onCommentsReady(source); + // Then + expect(sut).toBeInstanceOf(ExtendTypes); // to avoid `no-new`. + expect(result).toBe(expectedResult); + }); +});