diff --git a/docs/usage/cli/index.md b/docs/usage/cli/index.md index d45089cb154..daf9304e0c6 100644 --- a/docs/usage/cli/index.md +++ b/docs/usage/cli/index.md @@ -41,6 +41,7 @@ Options: -i, --init generate a tslint.json config file in the current working directory -o, --out [out] output file --outputAbsolutePaths whether or not outputted file paths are absolute +--print-config print resolved configuration for a file -r, --rules-dir [rules-dir] rules directory -s, --formatters-dir [formatters-dir] formatters directory -t, --format [format] output format (prose, json, stylish, verbose, pmd, msbuild, checkstyle, vso, fileslist, codeFrame) diff --git a/src/configuration.ts b/src/configuration.ts index 712f8cc55eb..baf9d59bf0c 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -660,3 +660,24 @@ export function isFileExcluded(filepath: string, configFile?: IConfigurationFile const fullPath = path.resolve(filepath); return configFile.linterOptions.exclude.some(pattern => new Minimatch(pattern).match(fullPath)); } + +export function stringifyConfiguration(configFile: IConfigurationFile) { + return JSON.stringify( + { + extends: configFile.extends, + jsRules: convertRulesMapToObject(configFile.jsRules), + linterOptions: configFile.linterOptions, + rules: convertRulesMapToObject(configFile.rules), + rulesDirectory: configFile.rulesDirectory, + }, + undefined, + 2, + ); +} + +function convertRulesMapToObject(rules: Map>) { + return Array.from(rules).reduce<{ [i: string]: Partial }>( + (result, [key, value]) => ({ ...result, [key]: value }), + {}, + ); +} diff --git a/src/files/reading.ts b/src/files/reading.ts new file mode 100644 index 00000000000..a91bc23ca1a --- /dev/null +++ b/src/files/reading.ts @@ -0,0 +1,44 @@ +/** + * @license + * Copyright 2019 Palantir Technologies, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as fs from "fs"; + +import { FatalError } from "../error"; +import { Logger } from "../runner"; + +/** Read a file, but return undefined if it is an MPEG '.ts' file. */ +export async function tryReadFile(filename: string, logger: Logger): Promise { + if (!fs.existsSync(filename)) { + throw new FatalError(`Unable to open file: ${filename}`); + } + const buffer = Buffer.allocUnsafe(256); + const fd = fs.openSync(filename, "r"); + try { + fs.readSync(fd, buffer, 0, 256, 0); + if (buffer.readInt8(0) === 0x47 && buffer.readInt8(188) === 0x47) { + // MPEG transport streams use the '.ts' file extension. They use 0x47 as the frame + // separator, repeating every 188 bytes. It is unlikely to find that pattern in + // TypeScript source, so tslint ignores files with the specific pattern. + logger.error(`${filename}: ignoring MPEG transport stream\n`); + return undefined; + } + } finally { + fs.closeSync(fd); + } + + return fs.readFileSync(filename, "utf8"); +} diff --git a/src/files/resolution.ts b/src/files/resolution.ts new file mode 100644 index 00000000000..dc4a4dfed40 --- /dev/null +++ b/src/files/resolution.ts @@ -0,0 +1,109 @@ +/** + * @license + * Copyright 2019 Palantir Technologies, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as fs from "fs"; +import * as glob from "glob"; +import { filter as createMinimatchFilter, Minimatch } from "minimatch"; +import * as path from "path"; +import * as ts from "typescript"; + +import { FatalError } from "../error"; +import { Linter } from "../linter"; +import { Logger, Options } from "../runner"; +import { flatMap, trimSingleQuotes } from "../utils"; + +export function filterFiles(files: string[], patterns: string[], include: boolean): string[] { + if (patterns.length === 0) { + return include ? [] : files; + } + const matcher = patterns.map(pattern => new Minimatch(pattern, { dot: !include })); // `glob` always enables `dot` for ignore patterns + return files.filter(file => include === matcher.some(pattern => pattern.match(file))); +} + +export function findTsconfig(project: string): string | undefined { + try { + const stats = fs.statSync(project); // throws if file does not exist + if (!stats.isDirectory()) { + return project; + } + const projectFile = path.join(project, "tsconfig.json"); + fs.accessSync(projectFile); // throws if file does not exist + return projectFile; + } catch (e) { + return undefined; + } +} + +export function resolveGlobs( + files: string[], + ignore: string[], + outputAbsolutePaths: boolean | undefined, + logger: Logger, +): string[] { + const results = flatMap(files, file => + glob.sync(trimSingleQuotes(file), { ignore, nodir: true }), + ); + // warn if `files` contains non-existent files, that are not patters and not excluded by any of the exclude patterns + for (const file of filterFiles(files, ignore, false)) { + if (!glob.hasMagic(file) && !results.some(createMinimatchFilter(file))) { + logger.error(`'${file}' does not exist. This will be an error in TSLint 6.\n`); // TODO make this an error in v6.0.0 + } + } + const cwd = process.cwd(); + return results.map(file => + outputAbsolutePaths ? path.resolve(cwd, file) : path.relative(cwd, file), + ); +} + +export function resolveFilesAndProgram( + { files, project, exclude, outputAbsolutePaths }: Options, + logger: Logger, +): { files: string[]; program?: ts.Program } { + // remove single quotes which break matching on Windows when glob is passed in single quotes + exclude = exclude.map(trimSingleQuotes); + + if (project === undefined) { + return { files: resolveGlobs(files, exclude, outputAbsolutePaths, logger) }; + } + + const projectPath = findTsconfig(project); + if (projectPath === undefined) { + throw new FatalError(`Invalid option for project: ${project}`); + } + + exclude = exclude.map(pattern => path.resolve(pattern)); + const program = Linter.createProgram(projectPath); + let filesFound: string[]; + if (files.length === 0) { + filesFound = filterFiles(Linter.getFileNames(program), exclude, false); + } else { + files = files.map(f => path.resolve(f)); + filesFound = filterFiles(program.getSourceFiles().map(f => f.fileName), files, true); + filesFound = filterFiles(filesFound, exclude, false); + + // find non-glob files that have no matching file in the project and are not excluded by any exclude pattern + for (const file of filterFiles(files, exclude, false)) { + if (!glob.hasMagic(file) && !filesFound.some(createMinimatchFilter(file))) { + if (fs.existsSync(file)) { + throw new FatalError(`'${file}' is not included in project.`); + } + logger.error(`'${file}' does not exist. This will be an error in TSLint 6.\n`); // TODO make this an error in v6.0.0 + } + } + } + return { files: filesFound, program }; +} diff --git a/src/runner.ts b/src/runner.ts index 2d4f3505d3c..7e47595a226 100644 --- a/src/runner.ts +++ b/src/runner.ts @@ -18,21 +18,23 @@ // tslint:disable strict-boolean-expressions (TODO: Fix up options) import * as fs from "fs"; -import * as glob from "glob"; -import { filter as createMinimatchFilter, Minimatch } from "minimatch"; import * as path from "path"; import * as ts from "typescript"; import { DEFAULT_CONFIG, findConfiguration, + findConfigurationPath, isFileExcluded, JSON_CONFIG_FILENAME, + stringifyConfiguration, } from "./configuration"; import { FatalError } from "./error"; +import { tryReadFile } from "./files/reading"; +import { resolveFilesAndProgram } from "./files/resolution"; import { LintResult } from "./index"; import { Linter } from "./linter"; -import { flatMap } from "./utils"; +import { trimSingleQuotes } from "./utils"; export interface Options { /** @@ -85,6 +87,11 @@ export interface Options { */ outputAbsolutePaths?: boolean; + /** + * Outputs the configuration to be used instead of linting. + */ + printConfig?: boolean; + /** * tsconfig.json file. */ @@ -145,6 +152,10 @@ async function runWorker(options: Options, logger: Logger): Promise { return Status.Ok; } + if (options.printConfig) { + return printConfiguration(options, logger); + } + if (options.test) { const test = await import("./test"); const results = test.runTests( @@ -165,6 +176,29 @@ async function runWorker(options: Options, logger: Logger): Promise { return options.force || errorCount === 0 ? Status.Ok : Status.LintError; } +async function printConfiguration(options: Options, logger: Logger): Promise { + const { files } = options; + if (files.length !== 1) { + throw new FatalError(`--print-config must be run with exactly one file`); + } + + const configurationPath = + options.config === undefined ? findConfigurationPath(null, files[0]) : options.config; + if (configurationPath === undefined) { + throw new FatalError( + `Could not find configuration path. Try passing a --config to your tslint.json.`, + ); + } + + const configuration = findConfiguration(configurationPath, files[0]).results; + if (configuration === undefined) { + throw new FatalError(`Could not find configuration for '${files[1]}`); + } + + logger.log(`${stringifyConfiguration(configuration)}\n`); + return Status.Ok; +} + async function runLinter(options: Options, logger: Logger): Promise { const { files, program } = resolveFilesAndProgram(options, logger); // if type checking, run the type checker @@ -184,74 +218,6 @@ async function runLinter(options: Options, logger: Logger): Promise return doLinting(options, files, program, logger); } -function resolveFilesAndProgram( - { files, project, exclude, outputAbsolutePaths }: Options, - logger: Logger, -): { files: string[]; program?: ts.Program } { - // remove single quotes which break matching on Windows when glob is passed in single quotes - exclude = exclude.map(trimSingleQuotes); - - if (project === undefined) { - return { files: resolveGlobs(files, exclude, outputAbsolutePaths, logger) }; - } - - const projectPath = findTsconfig(project); - if (projectPath === undefined) { - throw new FatalError(`Invalid option for project: ${project}`); - } - - exclude = exclude.map(pattern => path.resolve(pattern)); - const program = Linter.createProgram(projectPath); - let filesFound: string[]; - if (files.length === 0) { - filesFound = filterFiles(Linter.getFileNames(program), exclude, false); - } else { - files = files.map(f => path.resolve(f)); - filesFound = filterFiles(program.getSourceFiles().map(f => f.fileName), files, true); - filesFound = filterFiles(filesFound, exclude, false); - - // find non-glob files that have no matching file in the project and are not excluded by any exclude pattern - for (const file of filterFiles(files, exclude, false)) { - if (!glob.hasMagic(file) && !filesFound.some(createMinimatchFilter(file))) { - if (fs.existsSync(file)) { - throw new FatalError(`'${file}' is not included in project.`); - } - logger.error(`'${file}' does not exist. This will be an error in TSLint 6.\n`); // TODO make this an error in v6.0.0 - } - } - } - return { files: filesFound, program }; -} - -function filterFiles(files: string[], patterns: string[], include: boolean): string[] { - if (patterns.length === 0) { - return include ? [] : files; - } - const matcher = patterns.map(pattern => new Minimatch(pattern, { dot: !include })); // `glob` always enables `dot` for ignore patterns - return files.filter(file => include === matcher.some(pattern => pattern.match(file))); -} - -function resolveGlobs( - files: string[], - ignore: string[], - outputAbsolutePaths: boolean | undefined, - logger: Logger, -): string[] { - const results = flatMap(files, file => - glob.sync(trimSingleQuotes(file), { ignore, nodir: true }), - ); - // warn if `files` contains non-existent files, that are not patters and not excluded by any of the exclude patterns - for (const file of filterFiles(files, ignore, false)) { - if (!glob.hasMagic(file) && !results.some(createMinimatchFilter(file))) { - logger.error(`'${file}' does not exist. This will be an error in TSLint 6.\n`); // TODO make this an error in v6.0.0 - } - } - const cwd = process.cwd(); - return results.map(file => - outputAbsolutePaths ? path.resolve(cwd, file) : path.relative(cwd, file), - ); -} - async function doLinting( options: Options, files: string[], @@ -312,29 +278,6 @@ async function doLinting( return linter.getResult(); } -/** Read a file, but return undefined if it is an MPEG '.ts' file. */ -async function tryReadFile(filename: string, logger: Logger): Promise { - if (!fs.existsSync(filename)) { - throw new FatalError(`Unable to open file: ${filename}`); - } - const buffer = Buffer.allocUnsafe(256); - const fd = fs.openSync(filename, "r"); - try { - fs.readSync(fd, buffer, 0, 256, 0); - if (buffer.readInt8(0) === 0x47 && buffer.readInt8(188) === 0x47) { - // MPEG transport streams use the '.ts' file extension. They use 0x47 as the frame - // separator, repeating every 188 bytes. It is unlikely to find that pattern in - // TypeScript source, so tslint ignores files with the specific pattern. - logger.error(`${filename}: ignoring MPEG transport stream\n`); - return undefined; - } - } finally { - fs.closeSync(fd); - } - - return fs.readFileSync(filename, "utf8"); -} - function showDiagnostic( { file, start, category, messageText }: ts.Diagnostic, program: ts.Program, @@ -351,21 +294,3 @@ function showDiagnostic( } return `${message} ${ts.flattenDiagnosticMessageText(messageText, "\n")}`; } - -function trimSingleQuotes(str: string): string { - return str.replace(/^'|'$/g, ""); -} - -function findTsconfig(project: string): string | undefined { - try { - const stats = fs.statSync(project); // throws if file does not exist - if (!stats.isDirectory()) { - return project; - } - const projectFile = path.join(project, "tsconfig.json"); - fs.accessSync(projectFile); // throws if file does not exist - return projectFile; - } catch (e) { - return undefined; - } -} diff --git a/src/tslintCli.ts b/src/tslintCli.ts index 7f9d0a6cbb7..a980480dd7e 100644 --- a/src/tslintCli.ts +++ b/src/tslintCli.ts @@ -35,6 +35,7 @@ interface Argv { init?: boolean; out?: string; outputAbsolutePaths: boolean; + printConfig?: boolean; project?: string; rulesDir?: string; formattersDir: string; @@ -48,7 +49,7 @@ interface Argv { interface Option { short?: string; // Commander will camelCase option names. - name: keyof Argv | "rules-dir" | "formatters-dir" | "type-check"; + name: keyof Argv | "rules-dir" | "formatters-dir" | "print-config" | "type-check"; type: "string" | "boolean" | "array"; describe: string; // Short, used for usage message description: string; // Long, used for `--help` @@ -121,6 +122,15 @@ const options: Option[] = [ describe: "whether or not outputted file paths are absolute", description: "If true, all paths in the output will be absolute.", }, + { + name: "print-config", + type: "boolean", + describe: "print resolved configuration for a file", + description: dedent` + When passed a single file name, prints the configuration that would + be used to lint that file. + No linting is performed and only config-related options are valid.`, + }, { short: "r", name: "rules-dir", @@ -296,6 +306,7 @@ run( init: argv.init, out: argv.out, outputAbsolutePaths: argv.outputAbsolutePaths, + printConfig: argv.printConfig, project: argv.project, quiet: argv.quiet, rulesDirectory: argv.rulesDir, diff --git a/src/utils.ts b/src/utils.ts index 4ca158a47b1..b309c0fe869 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -235,6 +235,10 @@ export function detectBufferEncoding(buffer: Buffer, length = buffer.length): En return "utf8"; } +export function trimSingleQuotes(str: string): string { + return str.replace(/^'|'$/g, ""); +} + // converts Windows normalized paths (with backwards slash `\`) to paths used by TypeScript (with forward slash `/`) export function denormalizeWinPath(path: string): string { return path.replace(/\\/g, "/"); diff --git a/test/configurationTests.ts b/test/configurationTests.ts index 2db73d0fa61..3305a510023 100644 --- a/test/configurationTests.ts +++ b/test/configurationTests.ts @@ -26,6 +26,7 @@ import { loadConfigurationFromPath, parseConfigFile, RawConfigFile, + stringifyConfiguration, } from "../src/configuration"; import { IOptions, RuleSeverity } from "../src/language/rule/rule"; @@ -681,6 +682,158 @@ describe("Configuration", () => { }); }); }); + + describe("stringifyConfiguration", () => { + const blankConfiguration: IConfigurationFile = { + extends: [], + jsRules: new Map(), + rules: new Map(), + rulesDirectory: [], + }; + + it("stringifies an empty configuration", () => { + const actual = stringifyConfiguration(blankConfiguration); + + assert.equal( + actual, + JSON.stringify( + { + extends: [], + jsRules: {}, + rules: {}, + rulesDirectory: [], + }, + undefined, + 2, + ), + ); + }); + + it("stringifies a configuration with jsRules", () => { + const configuration: IConfigurationFile = { + ...blankConfiguration, + jsRules: new Map([ + [ + "js-rule", + { + ruleArguments: ["sample", "argument"], + ruleName: "js-rule", + }, + ], + ]), + }; + + const actual = stringifyConfiguration(configuration); + + assert.equal( + actual, + JSON.stringify( + { + extends: [], + jsRules: { + "js-rule": { + ruleArguments: ["sample", "argument"], + ruleName: "js-rule", + }, + }, + rules: {}, + rulesDirectory: [], + }, + undefined, + 2, + ), + ); + }); + + it("stringifies a configuration with linterOptions", () => { + const configuration: IConfigurationFile = { + ...blankConfiguration, + linterOptions: { + exclude: ["./sample/**/*.ts"], + format: "sample-format", + }, + }; + + const actual = stringifyConfiguration(configuration); + + assert.equal( + actual, + JSON.stringify( + { + extends: [], + jsRules: {}, + linterOptions: { + exclude: ["./sample/**/*.ts"], + format: "sample-format", + }, + rules: {}, + rulesDirectory: [], + }, + undefined, + 2, + ), + ); + }); + + it("stringifies a configuration with rules", () => { + const configuration: IConfigurationFile = { + ...blankConfiguration, + rules: new Map([ + [ + "ts-rule", + { + ruleArguments: ["sample", "argument"], + ruleName: "ts-rule", + }, + ], + ]), + }; + + const actual = stringifyConfiguration(configuration); + + assert.equal( + actual, + JSON.stringify( + { + extends: [], + jsRules: {}, + rules: { + "ts-rule": { + ruleArguments: ["sample", "argument"], + ruleName: "ts-rule", + }, + }, + rulesDirectory: [], + }, + undefined, + 2, + ), + ); + }); + + it("stringifies a configuration with rulesDirectory", () => { + const configuration: IConfigurationFile = { + ...blankConfiguration, + rulesDirectory: ["./directory/one", "./directory/two"], + }; + + const actual = stringifyConfiguration(configuration); + + assert.equal( + actual, + JSON.stringify( + { + extends: [], + jsRules: {}, + rules: {}, + rulesDirectory: ["./directory/one", "./directory/two"], + }, + undefined, + 2, + ), + ); + }); + }); }); function getEmptyConfig(): IConfigurationFile { diff --git a/test/executable/executableTests.ts b/test/executable/executableTests.ts index a1cd77b12c6..51a8d7e6e0b 100644 --- a/test/executable/executableTests.ts +++ b/test/executable/executableTests.ts @@ -435,6 +435,35 @@ describe("Executable", function(this: Mocha.Suite) { }); }); + describe("--print-config flag", () => { + it("exits with code 1 if no files are provided", async () => { + const status = await execRunner({ + files: [], + printConfig: true, + }); + + assert.equal(status, Status.FatalError); + }); + + it("exits with code 0 if one file is provided", async () => { + const status = await execRunner({ + files: ["test/files/a.ts"], + printConfig: true, + }); + + assert.equal(status, Status.Ok); + }); + + it("exits with code 1 if multiple files are provided", async () => { + const status = await execRunner({ + files: ["test/files/a.ts", "test/files//b.ts"], + printConfig: true, + }); + + assert.equal(status, Status.FatalError); + }); + }); + describe("--project flag", () => { it("exits with code 0 if `tsconfig.json` is passed and it specifies files without errors", async () => { const status = await execRunner({ diff --git a/test/files/print-config/a.ts b/test/files/print-config/a.ts new file mode 100644 index 00000000000..7b2a3460115 --- /dev/null +++ b/test/files/print-config/a.ts @@ -0,0 +1 @@ +console.log("a"); diff --git a/test/files/print-config/b.ts b/test/files/print-config/b.ts new file mode 100644 index 00000000000..6d012e7f1f1 --- /dev/null +++ b/test/files/print-config/b.ts @@ -0,0 +1 @@ +console.log("b");