diff --git a/README.md b/README.md index f88739e01..cdda7299f 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ We **strongly** advise reading [docs/FAQs.md](./docs/FAQs.md) before planning yo Each of these flags is optional: +- **[`comments`](#comments)**: File glob path(s) to convert TSLint rule flags to ESLint within. - **[`config`](#config)**: Path to print the generated ESLint configuration file to. - **[`editor`](#editor)**: Path to an editor configuration file to convert linter settings within. - **[`eslint`](#eslint)**: Path to an ESLint configuration file to read settings from. @@ -50,6 +51,17 @@ Each of these flags is optional: - **[`tslint`](#tslint)**: Path to a TSLint configuration file to read settings from. - **[`typescript`](#typescript)**: Path to a TypeScript configuration file to read TypeScript compiler options from. +#### `comments` + +```shell +npx tslint-to-eslint-config --comments src/**/*.ts +``` + +_Default: none_ + +File glob path(s) to convert [TSLint rule flags](https://palantir.github.io/tslint/usage/rule-flags) to [ESLint inline comments](https://eslint.org/docs/user-guide/configuring#disabling-rules-with-inline-comments) in. +Comments such as `// tslint:disable: tslint-rule-name` will be converted to equivalents like `// eslint-disable eslint-rule-name`. + #### `config` ```shell diff --git a/docs/Architecture.md b/docs/Architecture.md index d7c10dc4a..4ec5bc349 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -16,7 +16,8 @@ Within `src/conversion/convertConfig.ts`, the following steps occur: 3. ESLint configurations are simplified based on extended ESLint and TSLint presets - 3a. If no output rules conflict with `eslint-config-prettier`, it's added in 4. The simplified configuration is written to the output config file -5. A summary of the results is printed to the user's console +5. Files to transform comments in have source text rewritten using the same rule conversion logic +6. A summary of the results is printed to the user's console ### Conversion Results diff --git a/package-lock.json b/package-lock.json index 9d14223e9..70cd76e09 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3208,6 +3208,23 @@ "integrity": "sha512-OCutwjDZ4aFS6PB1UZ988C4YgwlBHJd6wCeQqaLdmadZ/7e+w79+hbMUFC1QXDNCmdyoRfAFdm0RypzwR+Qpag==", "dev": true }, + "@types/events": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/events/-/events-3.0.0.tgz", + "integrity": "sha512-EaObqwIvayI5a8dCzhFrjKzVwKLxjoG9T6Ppd5CEo07LRKfQ8Yokw54r5+Wq7FaBQ+yXRvQAYPrHwya1/UFt9g==", + "dev": true + }, + "@types/glob": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@types/glob/-/glob-7.1.1.tgz", + "integrity": "sha512-1Bh06cbWJUHMC97acuD6UMG29nMt0Aqz1vF3guLfG+kHHJhy3AyohZFFxYk2f7Q1SQIrNwvncxAE0N/9s70F2w==", + "dev": true, + "requires": { + "@types/events": "*", + "@types/minimatch": "*", + "@types/node": "*" + } + }, "@types/istanbul-lib-coverage": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz", @@ -3249,6 +3266,12 @@ "integrity": "sha512-8+KAKzEvSUdeo+kmqnKrqgeE+LcA0tjYWFY7RPProVYwnqDjukzO+3b6dLD56rYX5TdWejnEOLJYOIeh4CXKuA==", "dev": true }, + "@types/minimatch": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz", + "integrity": "sha512-tHq6qdbT9U1IRSGf14CL0pUlULksvY9OZ+5eEgl1N7t+OA3tGvNpxJCzuKQlsNgCVwbAs670L1vcVQi8j9HjnA==", + "dev": true + }, "@types/node": { "version": "12.12.21", "resolved": "https://registry.npmjs.org/@types/node/-/node-12.12.21.tgz", @@ -5083,9 +5106,9 @@ } }, "glob": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.4.tgz", - "integrity": "sha512-hkLPepehmnKk41pUGm3sYxoFs/umurYfYJCerbXEyFIWcAzvpipAgVkBqqT9RBKMGjnq6kMuyYwha6csxbiM1A==", + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", "requires": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", diff --git a/package.json b/package.json index f4283c0df..aed07541e 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "chalk": "4.0.0", "commander": "5.1.0", "eslint-config-prettier": "6.11.0", + "glob": "7.1.6", "strip-json-comments": "3.1.0", "tslint": "6.1.2", "typescript": "3.8.3" @@ -23,6 +24,7 @@ "@babel/plugin-proposal-optional-chaining": "7.9.0", "@babel/preset-env": "7.9.5", "@babel/preset-typescript": "7.9.0", + "@types/glob": "^7.1.1", "@types/jest": "25.2.1", "@types/node": "12.12.21", "@typescript-eslint/eslint-plugin": "2.29.0", diff --git a/src/adapters/fileSystem.stub.ts b/src/adapters/fileSystem.stub.ts index eb1fdcc00..b962f53e6 100644 --- a/src/adapters/fileSystem.stub.ts +++ b/src/adapters/fileSystem.stub.ts @@ -3,9 +3,3 @@ export const createStubFileSystem = ({ data = {}, exists = true } = {}) => ({ readFile: jest.fn().mockReturnValue(Promise.resolve(data)), writeFile: jest.fn(), }); - -export const createStubThrowingFileSystem = ({ err = "" } = {}) => ({ - fileExists: jest.fn().mockRejectedValue(Promise.resolve(new Error(err))), - readFile: jest.fn().mockRejectedValue(Promise.resolve(new Error(err))), - writeFile: jest.fn().mockRejectedValue(Promise.resolve(new Error(err))), -}); diff --git a/src/adapters/globAsync.ts b/src/adapters/globAsync.ts new file mode 100644 index 000000000..d9d91bbb4 --- /dev/null +++ b/src/adapters/globAsync.ts @@ -0,0 +1,11 @@ +import glob from "glob"; + +export const globAsync = async (pattern: string) => { + return new Promise((resolve) => { + glob(pattern, (error, matches) => { + resolve(error ?? matches); + }); + }); +}; + +export type GlobAsync = typeof globAsync; diff --git a/src/cli/main.ts b/src/cli/main.ts index a55117cfc..0b0f4bbb2 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -2,9 +2,15 @@ import { EOL } from "os"; import { childProcessExec } from "../adapters/childProcessExec"; import { fsFileSystem } from "../adapters/fsFileSystem"; +import { globAsync } from "../adapters/globAsync"; import { nativeImporter } from "../adapters/nativeImporter"; import { processLogger } from "../adapters/processLogger"; import { bind } from "../binding"; +import { convertComments, ConvertCommentsDependencies } from "../comments/convertComments"; +import { + ConvertFileCommentsDependencies, + convertFileComments, +} from "../comments/convertFileComments"; import { convertConfig, ConvertConfigDependencies } from "../conversion/convertConfig"; import { convertEditorConfig, @@ -45,9 +51,13 @@ import { findTypeScriptConfiguration } from "../input/findTypeScriptConfiguratio import { importer, ImporterDependencies } from "../input/importer"; import { mergeLintConfigurations } from "../input/mergeLintConfigurations"; import { - ChoosePackageManagerDependencies, choosePackageManager, + ChoosePackageManagerDependencies, } from "../reporting/packages/choosePackageManager"; +import { + reportCommentResults, + ReportCommentResultsDependencies, +} from "../reporting/reportCommentResults"; import { logMissingPackages, LogMissingPackagesDependencies, @@ -62,6 +72,16 @@ import { mergers } from "../rules/mergers"; import { rulesConverters } from "../rules/rulesConverters"; import { runCli, RunCliDependencies } from "./runCli"; +const convertFileCommentsDependencies: ConvertFileCommentsDependencies = { + converters: rulesConverters, + fileSystem: fsFileSystem, +}; + +const convertCommentsDependencies: ConvertCommentsDependencies = { + convertFileComments: bind(convertFileComments, convertFileCommentsDependencies), + globAsync, +}; + const convertRulesDependencies: ConvertRulesDependencies = { converters: rulesConverters, mergers, @@ -97,6 +117,10 @@ const findOriginalConfigurationsDependencies: FindOriginalConfigurationsDependen mergeLintConfigurations, }; +const reportCommentResultsDependencies: ReportCommentResultsDependencies = { + logger: processLogger, +}; + const choosePackageManagerDependencies: ChoosePackageManagerDependencies = { fileSystem: fsFileSystem, }; @@ -124,12 +148,16 @@ const writeConversionResultsDependencies: WriteConversionResultsDependencies = { fileSystem: fsFileSystem, }; +const reportEditorSettingConversionResultsDependencies = { + logger: processLogger, +}; + const convertEditorConfigDependencies: ConvertEditorConfigDependencies = { findEditorConfiguration: bind(findEditorConfiguration, findEditorConfigurationDependencies), convertEditorSettings: bind(convertEditorSettings, convertEditorSettingsDependencies), reportConversionResults: bind( reportEditorSettingConversionResults, - reportConversionResultsDependencies, + reportEditorSettingConversionResultsDependencies, ), writeConversionResults: bind( writeEditorConfigConversionResults, @@ -138,12 +166,14 @@ const convertEditorConfigDependencies: ConvertEditorConfigDependencies = { }; const convertConfigDependencies: ConvertConfigDependencies = { + convertComments: bind(convertComments, convertCommentsDependencies), convertRules: bind(convertRules, convertRulesDependencies), findOriginalConfigurations: bind( findOriginalConfigurations, findOriginalConfigurationsDependencies, ), logMissingPackages: bind(logMissingPackages, logMissingPackagesDependencies), + reportCommentResults: bind(reportCommentResults, reportCommentResultsDependencies), reportConversionResults: bind(reportConversionResults, reportConversionResultsDependencies), simplifyPackageRules: bind(simplifyPackageRules, simplifyPackageRulesDependencies), writeConversionResults: bind(writeConversionResults, writeConversionResultsDependencies), diff --git a/src/cli/runCli.ts b/src/cli/runCli.ts index 92223d4fb..3cff71fc0 100644 --- a/src/cli/runCli.ts +++ b/src/cli/runCli.ts @@ -19,6 +19,10 @@ export const runCli = async ( ): Promise => { const command = new Command() .usage("[options] --language [language]") + .option( + "--comments [files]", + "convert tslint:disable rule flags in globbed files (experimental)", + ) .option("--config [config]", "eslint configuration file to output to") .option("--editor [editor]", "editor configuration file to convert") .option("--eslint [eslint]", "eslint configuration file to convert using") diff --git a/src/comments/convertComments.test.ts b/src/comments/convertComments.test.ts new file mode 100644 index 000000000..7f8eb2785 --- /dev/null +++ b/src/comments/convertComments.test.ts @@ -0,0 +1,88 @@ +import { ResultStatus } from "../types"; +import { convertComments, ConvertCommentsDependencies } from "./convertComments"; + +const createStubDependencies = ( + overrides: Partial = {}, +): ConvertCommentsDependencies => ({ + convertFileComments: jest.fn(), + globAsync: jest.fn().mockResolvedValue(["src/index.ts"]), + ...overrides, +}); + +describe("convertComments", () => { + it("returns an error when --comment is given as a boolean value", async () => { + // Arrange + const dependencies = createStubDependencies(); + + // Act + const result = await convertComments(dependencies, true); + + // Assert + expect(result).toEqual({ + errors: expect.arrayContaining([expect.any(Error)]), + status: ResultStatus.Failed, + }); + }); + + it("returns an error when there are no file path globs", async () => { + // Arrange + const dependencies = createStubDependencies(); + + // Act + const result = await convertComments(dependencies, []); + + // Assert + expect(result).toEqual({ + errors: expect.arrayContaining([expect.any(Error)]), + status: ResultStatus.Failed, + }); + }); + + it("returns the failure result when a file path glob fails", async () => { + // Arrange + const globAsyncError = new Error(); + const dependencies = createStubDependencies({ + globAsync: jest.fn().mockResolvedValueOnce(globAsyncError), + }); + + // Act + const result = await convertComments(dependencies, ["*.ts"]); + + // Assert + expect(result).toEqual({ + errors: [globAsyncError], + status: ResultStatus.Failed, + }); + }); + + it("returns the failure result when a file conversion fails", async () => { + // Arrange + const fileConversionError = new Error(); + const dependencies = createStubDependencies({ + convertFileComments: jest.fn().mockResolvedValueOnce(fileConversionError), + }); + + // Act + const result = await convertComments(dependencies, ["*.ts"]); + + // Assert + expect(result).toEqual({ + errors: [fileConversionError], + status: ResultStatus.Failed, + }); + }); + + it("returns a success result when all steps succeed", async () => { + // Arrange + const dependencies = createStubDependencies(); + + // Act + const result = await convertComments(dependencies, ["*.ts"]); + + // Assert + expect(result).toEqual({ + data: ["src/index.ts"], + status: ResultStatus.Succeeded, + }); + }); +}); diff --git a/src/comments/convertComments.ts b/src/comments/convertComments.ts new file mode 100644 index 000000000..dad74e034 --- /dev/null +++ b/src/comments/convertComments.ts @@ -0,0 +1,60 @@ +import { GlobAsync } from "../adapters/globAsync"; +import { SansDependencies } from "../binding"; +import { ResultStatus, ResultWithDataStatus } from "../types"; +import { separateErrors, uniqueFromSources, isError } from "../utils"; +import { convertFileComments } from "./convertFileComments"; + +export type ConvertCommentsDependencies = { + convertFileComments: SansDependencies; + globAsync: GlobAsync; +}; + +const noGlobsResult: ResultWithDataStatus = { + errors: [new Error("--comment requires file path globs to be passed.")], + status: ResultStatus.Failed, +}; + +export const convertComments = async ( + dependencies: ConvertCommentsDependencies, + filePathGlobs: true | string | string[] | undefined, +): Promise> => { + if (filePathGlobs === true) { + return noGlobsResult; + } + + const uniqueFilePathGlobs = uniqueFromSources(filePathGlobs); + if (uniqueFilePathGlobs.join("") === "") { + return noGlobsResult; + } + + const [fileGlobErrors, globbedFilePaths] = separateErrors( + await Promise.all(uniqueFilePathGlobs.map(dependencies.globAsync)), + ); + if (fileGlobErrors.length !== 0) { + return { + errors: fileGlobErrors, + status: ResultStatus.Failed, + }; + } + + const ruleConversionCache = new Map(); + const uniqueGlobbedFilePaths = uniqueFromSources(...globbedFilePaths); + const fileFailures = ( + await Promise.all( + uniqueGlobbedFilePaths.map(async (filePath) => + dependencies.convertFileComments(filePath, ruleConversionCache), + ), + ) + ).filter(isError); + if (fileFailures.length !== 0) { + return { + errors: fileFailures, + status: ResultStatus.Failed, + }; + } + + return { + data: uniqueGlobbedFilePaths, + status: ResultStatus.Succeeded, + }; +}; diff --git a/src/comments/convertFileComments.test.ts b/src/comments/convertFileComments.test.ts new file mode 100644 index 000000000..244eb87ff --- /dev/null +++ b/src/comments/convertFileComments.test.ts @@ -0,0 +1,183 @@ +import { createStubFileSystem } from "../adapters/fileSystem.stub"; +import { ConversionError } from "../errors/conversionError"; +import { createStubConverter } from "../rules/converter.stubs"; +import { convertFileComments, ConvertFileCommentsDependencies } from "./convertFileComments"; + +const createStubDependencies = ( + readFileResult: string | Error, +): ConvertFileCommentsDependencies => ({ + converters: new Map([ + ["ts-a", createStubConverter(["es-a"])], + ["ts-b", createStubConverter(["es-b1", "es-b2"])], + ["ts-error", createStubConverter(ConversionError.forMerger("unknown"))], + ]), + fileSystem: { + ...createStubFileSystem(), + readFile: jest.fn().mockResolvedValueOnce(readFileResult), + }, +}); + +const stubFileName = "src/index.ts"; + +describe("convertFileComments", () => { + it("returns the failure result when reading the file fails", async () => { + // Arrange + const readFileError = new Error(); + const dependencies = createStubDependencies(readFileError); + + // Act + const result = await convertFileComments(dependencies, stubFileName, new Map()); + + // Assert + expect(result).toBe(readFileError); + }); + + it("doesn't overwrite a file when there are no matching comment directives", async () => { + // Arrange + const dependencies = createStubDependencies(` +// Hello, world! +`); + + // Act + await convertFileComments(dependencies, stubFileName, new Map()); + + // Assert + expect(dependencies.fileSystem.writeFile).not.toHaveBeenCalled(); + }); + + it("parses TSLint directives to their matching ESLint directives", async () => { + // Arrange + const dependencies = createStubDependencies(` +// tslint:disable +export const a = true; + +// tslint:disable-next-line +export const b = true; + +// tslint:enable +export const c = true; + +/* tslint:disable */ +export const d = true; + +/* tslint:disable-next-line */ +export const e = true; + +/* tslint:enable */ +export const f = true; +`); + + // Act + await convertFileComments(dependencies, stubFileName, new Map()); + + // Assert + expect(dependencies.fileSystem.writeFile).toHaveBeenCalledWith( + stubFileName, + ` +// eslint-disable +export const a = true; + +// eslint-disable-line +export const b = true; + +// eslint-enable +export const c = true; + +/* eslint-disable */ +export const d = true; + +/* eslint-disable-line */ +export const e = true; + +/* eslint-enable */ +export const f = true; +`, + ); + }); + + it("parses rule names when they exist", async () => { + // Arrange + const dependencies = createStubDependencies(` +// tslint:disable:ts-a +export const a = true; + +// tslint:disable-next-line: ts-a ts-b +export const b = true; +`); + + // Act + await convertFileComments(dependencies, stubFileName, new Map()); + + // Assert + expect(dependencies.fileSystem.writeFile).toHaveBeenCalledWith( + stubFileName, + ` +// eslint-disable es-a +export const a = true; + +// eslint-disable-line es-a, es-b1, es-b2 +export const b = true; +`, + ); + }); + + it("re-uses a rule conversion from cache when it was already converted", async () => { + // Arrange + const dependencies = createStubDependencies(` +// tslint:disable:ts-a +export const a = true; +`); + + // Act + await convertFileComments(dependencies, stubFileName, new Map([["ts-a", "es-cached"]])); + + // Assert + expect(dependencies.fileSystem.writeFile).toHaveBeenCalledWith( + stubFileName, + ` +// eslint-disable es-cached +export const a = true; +`, + ); + }); + + it("ignores comment text when there is no matching converter", async () => { + // Arrange + const dependencies = createStubDependencies(` +// tslint:disable:ts-z +export const a = true; +`); + + // Act + await convertFileComments(dependencies, stubFileName, new Map()); + + // Assert + expect(dependencies.fileSystem.writeFile).toHaveBeenCalledWith( + stubFileName, + ` +// eslint-disable +export const a = true; +`, + ); + }); + + it("ignores comment text when its matching converter results in an error", async () => { + // Arrange + const dependencies = createStubDependencies(` +// tslint:disable:ts-error +export const a = true; +`); + + // Act + await convertFileComments(dependencies, stubFileName, new Map()); + + // Assert + expect(dependencies.fileSystem.writeFile).toHaveBeenCalledWith( + stubFileName, + ` +// eslint-disable +export const a = true; +`, + ); + }); +}); diff --git a/src/comments/convertFileComments.ts b/src/comments/convertFileComments.ts new file mode 100644 index 000000000..de129ebd5 --- /dev/null +++ b/src/comments/convertFileComments.ts @@ -0,0 +1,32 @@ +import { FileSystem } from "../adapters/fileSystem"; +import { parseFileComments } from "./parseFileComments"; +import { replaceFileComments } from "./replaceFileComments"; +import { RuleConverter } from "../rules/converter"; + +export type ConvertFileCommentsDependencies = { + converters: Map; + fileSystem: Pick; +}; + +export const convertFileComments = async ( + dependencies: ConvertFileCommentsDependencies, + filePath: string, + ruleConversionCache: Map, +) => { + const fileContent = await dependencies.fileSystem.readFile(filePath); + if (fileContent instanceof Error) { + return fileContent; + } + + const comments = parseFileComments(filePath, fileContent); + const newFileContent = replaceFileComments( + fileContent, + comments, + dependencies.converters, + ruleConversionCache, + ); + + return fileContent === newFileContent + ? undefined + : await dependencies.fileSystem.writeFile(filePath, newFileContent); +}; diff --git a/src/comments/parseFileComments.ts b/src/comments/parseFileComments.ts new file mode 100644 index 000000000..a93490d6a --- /dev/null +++ b/src/comments/parseFileComments.ts @@ -0,0 +1,64 @@ +import * as utils from "tsutils"; +import * as ts from "typescript"; + +export type FileComment = { + commentKind: ts.CommentKind; + directive: TSLintDirective; + end: number; + pos: number; + ruleNames: string[]; +}; + +export type TSLintDirective = "tslint:disable" | "tslint:disable-next-line" | "tslint:enable"; + +/** + * @see https://github.com/Microsoft/TypeScript/issues/21049 + */ +export const parseFileComments = (filePath: string, content: string) => { + const sourceFile = ts.createSourceFile( + filePath, + content, + ts.ScriptTarget.Latest, + /*setParentNodes */ true, + ); + const directives: FileComment[] = []; + + utils.forEachComment(sourceFile, (fullText, comment) => { + const parsedComment = parseFileComment(fullText, comment); + if (parsedComment !== undefined) { + directives.push(parsedComment); + } + }); + + return directives; +}; + +/** + * @see https://github.com/palantir/tslint/blob/master/src/enableDisableRules.ts + */ +const tslintRegex = new RegExp(/tslint:(enable|disable)(?:-(line|next-line))?(:|\s|$)/g); + +const parseFileComment = (fullText: string, comment: ts.CommentRange): FileComment | undefined => { + const commentText = (comment.kind === ts.SyntaxKind.SingleLineCommentTrivia + ? fullText.substring(comment.pos + 2, comment.end) + : fullText.substring(comment.pos + 2, comment.end - 2) + ).trim(); + const match = commentText.match(tslintRegex); + if (match === null) { + return undefined; + } + + return { + commentKind: comment.kind, + directive: match[0].replace("e:", "e") as TSLintDirective, + end: comment.end, + pos: comment.pos, + ruleNames: commentText.slice(match[0].length).split(/\s+/).map(trimColons).filter(Boolean), + }; +}; + +const trimColons = (text: string) => + text + .replace(/^(:|\s)*/, "") + .replace(/(:|\s)*$/, "") + .trim(); diff --git a/src/comments/replaceFileComments.ts b/src/comments/replaceFileComments.ts new file mode 100644 index 000000000..81835b16b --- /dev/null +++ b/src/comments/replaceFileComments.ts @@ -0,0 +1,55 @@ +import * as ts from "typescript"; + +import { RuleConverter } from "../rules/converter"; +import { uniqueFromSources } from "../utils"; +import { ConversionError } from "../errors/conversionError"; +import { FileComment } from "./parseFileComments"; + +export const replaceFileComments = ( + content: string, + comments: FileComment[], + converters: Map, + ruleConversionCache: Map, +) => { + const getNewRuleLists = (ruleName: string) => { + if (ruleConversionCache.has(ruleName)) { + return ruleConversionCache.get(ruleName); + } + + const converter = converters.get(ruleName); + if (converter === undefined) { + ruleConversionCache.set(ruleName, undefined); + return undefined; + } + + const converted = converter({ ruleArguments: [] }); + return converted instanceof ConversionError + ? undefined + : converted.rules.map((conversion) => conversion.ruleName).join(", "); + }; + + for (const comment of [...comments].reverse()) { + const directive = comment.directive + .replace("tslint:", "eslint-") + .replace("next-line", "line"); + const ruleLists = uniqueFromSources(comment.ruleNames.map(getNewRuleLists)).filter(Boolean); + const [left, right] = + comment.commentKind === ts.SyntaxKind.SingleLineCommentTrivia + ? ["// ", ""] + : ["/* ", " */"]; + + content = [ + content.slice(0, comment.pos), + left, + directive, + ruleLists.length !== 0 && " ", + ruleLists.join(", "), + right, + content.slice(comment.end), + ] + .filter(Boolean) + .join(""); + } + + return content; +}; diff --git a/src/conversion/convertConfig.test.ts b/src/conversion/convertConfig.test.ts index 3fa1e53ea..d674366a5 100644 --- a/src/conversion/convertConfig.test.ts +++ b/src/conversion/convertConfig.test.ts @@ -8,11 +8,13 @@ const stubSettings = { const createStubDependencies = ( overrides: Partial = {}, ): ConvertConfigDependencies => ({ + convertComments: jest.fn(), convertRules: jest.fn(), findOriginalConfigurations: jest.fn().mockResolvedValue({ data: createStubOriginalConfigurationsData(), status: ResultStatus.Succeeded, }), + reportCommentResults: jest.fn(), reportConversionResults: jest.fn(), simplifyPackageRules: async (_configurations, data) => ({ ...data, @@ -72,16 +74,36 @@ describe("convertConfig", () => { }); }); - it("returns a successful result when finding the original configurations succeeds", async () => { + it("returns the failure result when converting comments fails", async () => { // Arrange - const dependencies = createStubDependencies(); + const convertCommentsResult = { + errors: [new Error()], + status: ResultStatus.Failed, + }; + const dependencies = createStubDependencies({ + convertComments: jest.fn().mockResolvedValueOnce(convertCommentsResult), + }); // Act const result = await convertConfig(dependencies, stubSettings); // Assert - expect(result).toEqual({ + expect(result).toEqual(convertCommentsResult); + }); + + it("returns a successful result when all steps succeed", async () => { + // Arrange + const convertCommentsResult = { status: ResultStatus.Succeeded, + }; + const dependencies = createStubDependencies({ + convertComments: jest.fn().mockResolvedValueOnce(convertCommentsResult), }); + + // Act + const result = await convertConfig(dependencies, stubSettings); + + // Assert + expect(result).toEqual(convertCommentsResult); }); }); diff --git a/src/conversion/convertConfig.ts b/src/conversion/convertConfig.ts index 4d965315e..774f80827 100644 --- a/src/conversion/convertConfig.ts +++ b/src/conversion/convertConfig.ts @@ -1,16 +1,20 @@ import { SansDependencies } from "../binding"; +import { convertComments } from "../comments/convertComments"; import { simplifyPackageRules } from "../creation/simplification/simplifyPackageRules"; import { writeConversionResults } from "../creation/writeConversionResults"; import { findOriginalConfigurations } from "../input/findOriginalConfigurations"; import { logMissingPackages } from "../reporting/packages/logMissingPackages"; import { reportConversionResults } from "../reporting/reportConversionResults"; +import { reportCommentResults } from "../reporting/reportCommentResults"; import { convertRules } from "../rules/convertRules"; import { ResultStatus, ResultWithStatus, TSLintToESLintSettings } from "../types"; export type ConvertConfigDependencies = { + convertComments: SansDependencies; convertRules: SansDependencies; findOriginalConfigurations: SansDependencies; logMissingPackages: SansDependencies; + reportCommentResults: SansDependencies; reportConversionResults: SansDependencies; simplifyPackageRules: SansDependencies; writeConversionResults: SansDependencies; @@ -56,14 +60,16 @@ export const convertConfig = async ( }; } - // 5. A summary of the results is printed to the user's console + // 5. Files to transform comments in have source text rewritten using the same rule conversion logic + const commentsResult = await dependencies.convertComments(settings.comments); + + // 6. A summary of the results is printed to the user's console await dependencies.reportConversionResults(settings.config, simplifiedConfiguration); + dependencies.reportCommentResults(settings.comments, commentsResult); await dependencies.logMissingPackages( simplifiedConfiguration, originalConfigurations.data.packages, ); - return { - status: ResultStatus.Succeeded, - }; + return commentsResult; }; diff --git a/src/input/findOriginalConfigurations.ts b/src/input/findOriginalConfigurations.ts index 872709f5d..4364c47a6 100644 --- a/src/input/findOriginalConfigurations.ts +++ b/src/input/findOriginalConfigurations.ts @@ -1,5 +1,10 @@ import { SansDependencies } from "../binding"; -import { ResultStatus, TSLintToESLintSettings, ResultWithDataStatus } from "../types"; +import { + ConfigurationErrorResult, + ResultStatus, + ResultWithDataStatus, + TSLintToESLintSettings, +} from "../types"; import { isDefined } from "../utils"; import { findESLintConfiguration, ESLintConfiguration } from "./findESLintConfiguration"; import { PackagesConfiguration, findPackagesConfiguration } from "./findPackagesConfiguration"; @@ -43,7 +48,7 @@ export type AllOriginalConfigurations = { export const findOriginalConfigurations = async ( dependencies: FindOriginalConfigurationsDependencies, rawSettings: TSLintToESLintSettings, -): Promise> => { +): Promise> => { // Simultaneously search for all required configuration types const [eslint, packages, tslint, typescript] = await Promise.all([ dependencies.findESLintConfiguration(rawSettings), diff --git a/src/reporting/reportCommentResults.test.ts b/src/reporting/reportCommentResults.test.ts new file mode 100644 index 000000000..114222be9 --- /dev/null +++ b/src/reporting/reportCommentResults.test.ts @@ -0,0 +1,96 @@ +import { createStubLogger, expectEqualWrites } from "../adapters/logger.stubs"; +import { ResultStatus } from "../types"; +import { reportCommentResults } from "./reportCommentResults"; + +describe("reportCommentResults", () => { + it("logs a suggestion when no comment globs are provided", () => { + // Arrange + const logger = createStubLogger(); + + // Act + reportCommentResults({ logger }, undefined, { data: [], status: ResultStatus.Succeeded }); + + // Assert + expectEqualWrites( + logger.stdout.write, + `♻ Consider using --comment to replace TSLint comment directives in your source files. ♻`, + ); + }); + + it("logs a singular complaint when one comment conversion fails", () => { + // Arrange + const logger = createStubLogger(); + const errors = [new Error("Hello")]; + + // Act + reportCommentResults({ logger }, ["src/index.ts"], { errors, status: ResultStatus.Failed }); + + // Assert + expectEqualWrites( + logger.stderr.write, + `❌ 1 error converting TSLint comment directives in --comment files. ❌`, + ` Check ${logger.debugFileName} for details.`, + ); + expectEqualWrites( + logger.info.write, + `1 error converting TSLint comment directives in --comment files:`, + ` * Hello`, + ); + }); + + it("logs a plural complaint when multiple comment conversions fail", () => { + // Arrange + const logger = createStubLogger(); + const errors = [new Error("Hello"), new Error("World")]; + + // Act + reportCommentResults({ logger }, ["src/index.ts"], { errors, status: ResultStatus.Failed }); + + // Assert + expectEqualWrites( + logger.stderr.write, + `❌ 2 errors converting TSLint comment directives in --comment files. ❌`, + ` Check ${logger.debugFileName} for details.`, + ); + expectEqualWrites( + logger.info.write, + `2 errors converting TSLint comment directives in --comment files:`, + ` * Hello`, + ` * World`, + ); + }); + + it("logs a singular success message when comment conversions succeeded on one file", () => { + // Arrange + const logger = createStubLogger(); + + // Act + reportCommentResults({ logger }, ["src/*.ts"], { + data: ["src/index.ts"], + status: ResultStatus.Succeeded, + }); + + // Assert + expectEqualWrites( + logger.stdout.write, + `♻ 1 file of TSLint comment directives converted to ESLint. ♻ `, + ); + }); + + it("logs a plural success message when comment conversions succeeded on two files", () => { + // Arrange + const logger = createStubLogger(); + + // Act + reportCommentResults({ logger }, ["src/*.ts"], { + data: ["src/index.ts", "src/data.ts"], + status: ResultStatus.Succeeded, + }); + + // Assert + expectEqualWrites( + logger.stdout.write, + `♻ 2 files of TSLint comment directives converted to ESLint. ♻ `, + ); + }); +}); diff --git a/src/reporting/reportCommentResults.ts b/src/reporting/reportCommentResults.ts new file mode 100644 index 000000000..d2d4f1141 --- /dev/null +++ b/src/reporting/reportCommentResults.ts @@ -0,0 +1,51 @@ +import chalk from "chalk"; +import { EOL } from "os"; + +import { Logger } from "../adapters/logger"; +import { ResultWithDataStatus, ResultStatus } from "../types"; + +export type ReportCommentResultsDependencies = { + logger: Logger; +}; + +export const reportCommentResults = ( + dependencies: ReportCommentResultsDependencies, + commentGlobs: string | string[] | undefined, + commentsResult: ResultWithDataStatus, +) => { + if (commentGlobs === undefined) { + dependencies.logger.stdout.write( + chalk.magentaBright( + `${EOL}♻ Consider using --comment to replace TSLint comment directives in your source files. ♻${EOL}`, + ), + ); + return; + } + + if (commentsResult.status === ResultStatus.Failed) { + const headline = `${commentsResult.errors.length} error${ + commentsResult.errors.length === 1 ? "" : "s" + } converting TSLint comment directives in --comment files`; + + dependencies.logger.stderr.write(chalk.magentaBright(`${EOL}❌ ${headline}. ❌${EOL}`)); + dependencies.logger.stderr.write( + chalk.magenta(` Check ${dependencies.logger.debugFileName} for details.${EOL}`), + ); + + dependencies.logger.info.write(`${headline}:${EOL}`); + dependencies.logger.info.write( + commentsResult.errors.map((error) => ` * ${error.message}${EOL}`).join(""), + ); + dependencies.logger.info.write(EOL); + return; + } + + dependencies.logger.stdout.write(chalk.magentaBright(`${EOL}♻ ${commentsResult.data.length}`)); + dependencies.logger.stdout.write( + chalk.magenta(` file${commentsResult.data.length === 1 ? "" : "s"}`), + ); + dependencies.logger.stdout.write( + chalk.magenta(` of TSLint comment directives converted to ESLint.`), + ); + dependencies.logger.stdout.write(chalk.magentaBright(` ♻${EOL}`)); +}; diff --git a/src/rules/converter.stubs.ts b/src/rules/converter.stubs.ts new file mode 100644 index 000000000..587c42d20 --- /dev/null +++ b/src/rules/converter.stubs.ts @@ -0,0 +1,11 @@ +import { ConversionError } from "../errors/conversionError"; + +export const createStubConverter = (result: ConversionError | string[]) => { + return () => { + return result instanceof ConversionError + ? result + : { + rules: result.map((ruleName) => ({ ruleName })), + }; + }; +}; diff --git a/src/types.ts b/src/types.ts index 8a3d7ec12..156efc71d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,6 +4,11 @@ export type TSLintToESLintSettings = { */ config: string; + /** + * File globs to convert `tslint:disable` comments within to `eslint-disable`. + */ + comments?: string | string[]; + /** * Original Editor configuration file path, such as `.vscode/settings.json`. */ @@ -45,10 +50,7 @@ export enum ResultStatus { export type ResultWithStatus = ConfigurationErrorResult | FailedResult | SucceededResult; -export type ResultWithDataStatus = - | ConfigurationErrorResult - | FailedResult - | SucceededDataResult; +export type ResultWithDataStatus = FailedResult | SucceededDataResult; export type ConfigurationErrorResult = { readonly complaints: string[]; diff --git a/src/utils.test.ts b/src/utils.test.ts index f0180a1ff..e93823af2 100644 --- a/src/utils.test.ts +++ b/src/utils.test.ts @@ -1,4 +1,4 @@ -import { isDefined, isError, uniqueFromSources } from "./utils"; +import { isDefined, isError, uniqueFromSources, separateErrors } from "./utils"; describe("isDefined", () => { it("returns true when the item is defined", () => { @@ -48,6 +48,19 @@ describe("isError", () => { }); }); +describe("separateErrors", () => { + it("splits the input array into errors and items", () => { + // Arrange + const mixed = ["value", new Error()]; + + // Act + const result = separateErrors(mixed); + + // Assert + expect(result).toEqual([[mixed[1]], [mixed[0]]]); + }); +}); + describe("uniqueFromSources", () => { it("returns unique items when multiple are given", () => { // Arange diff --git a/src/utils.ts b/src/utils.ts index 29c8d5e70..46de58cdb 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -4,11 +4,20 @@ export const isError = (item: Item | Error): item is Error => item instanc export const isTruthy = (item: Item | false | undefined | null | 0): item is Item => !!item; -export type RemoveErrors = { - [P in keyof Items]: Exclude; -}; +export const separateErrors = (mixed: (Error | Item)[]): [Error[], Item[]] => { + const errors: Error[] = []; + const items: Item[] = []; + + for (const item of mixed) { + if (item instanceof Error) { + errors.push(item); + } else { + items.push(item); + } + } -export type PromiseValue = T extends Promise ? R : never; + return [errors, items]; +}; export type RequireAtLeastOne = Pick> & {