diff --git a/conf/config-schema.js b/conf/config-schema.js index 626e1d54c52..a32e5cd6194 100644 --- a/conf/config-schema.js +++ b/conf/config-schema.js @@ -11,6 +11,7 @@ const baseConfigProperties = { parser: { type: ["string", "null"] }, parserOptions: { type: "object" }, plugins: { type: "array" }, + processor: { type: "string" }, rules: { type: "object" }, settings: { type: "object" }, diff --git a/docs/developer-guide/nodejs-api.md b/docs/developer-guide/nodejs-api.md index 74b4796e9df..4bc62221417 100644 --- a/docs/developer-guide/nodejs-api.md +++ b/docs/developer-guide/nodejs-api.md @@ -96,8 +96,10 @@ The most important method on `Linter` is `verify()`, which initiates linting of * **Note**: If you want to lint text and have your configuration be read and processed, use CLIEngine's [`executeOnFiles`](#cliengineexecuteonfiles) or [`executeOnText`](#cliengineexecuteontext) instead. * `options` - (optional) Additional options for this run. * `filename` - (optional) the filename to associate with the source code. - * `preprocess` - (optional) A function that accepts a string containing source text, and returns an array of strings containing blocks of code to lint. Also see: [Processors in Plugins](/docs/developer-guide/working-with-plugins.md#processors-in-plugins) - * `postprocess` - (optional) A function that accepts an array of problem lists (one list of problems for each block of code from `preprocess`), and returns a one-dimensional array of problems containing problems for the original, unprocessed text. Also see: [Processors in Plugins](/docs/developer-guide/working-with-plugins.md#processors-in-plugins) + * `preprocess` - (optional) A function that [Processors in Plugins](/docs/developer-guide/working-with-plugins.md#processors-in-plugins) describes as the `preprocess` method. + * `postprocess` - (optional) A function that [Processors in Plugins](/docs/developer-guide/working-with-plugins.md#processors-in-plugins) describes as the `postprocess` method. + * `filterCodeBlock` - (optional) A function that decides which code blocks the linter should adopt. The function receives two arguments. The first argument is the virtual filename of a code block. The second argument is the text of the code block. If the function returned `true` then the linter adopts the code block. If the function was omitted, the linter adopts only `*.js` code blocks. + * `disableFixes` - (optional) when set to `true`, the linter doesn't make the `fix` property of the lint result. * `allowInlineConfig` - (optional) set to `false` to disable inline comments from changing ESLint rules. * `reportUnusedDisableDirectives` - (optional) when set to `true`, adds reported errors for unused `eslint-disable` directives when no problems would be reported in the disabled area anyway. diff --git a/docs/developer-guide/working-with-plugins.md b/docs/developer-guide/working-with-plugins.md index 83f107512d0..6c05bbf83fc 100644 --- a/docs/developer-guide/working-with-plugins.md +++ b/docs/developer-guide/working-with-plugins.md @@ -54,15 +54,16 @@ You can also create plugins that would tell ESLint how to process files other th ```js module.exports = { processors: { - - // assign to the file extension you want (.js, .jsx, .html, etc.) - ".ext": { + "processor-name": { // takes text of the file and filename preprocess: function(text, filename) { // here, you can strip out any non-JS content // and split into multiple strings to lint - return [string]; // return an array of strings to lint + return [ // return an array of code blocks to lint + { text: code1, filename: "0.js" }, + { text: code2, filename: "1.js" }, + ]; }, // takes a Message[][] and filename @@ -72,7 +73,7 @@ module.exports = { // to the text that was returned in array from preprocess() method // you need to return a one-dimensional array of the messages you want to keep - return messages[0]; + return [].concat(...messages); }, supportsAutofix: true // (optional, defaults to false) @@ -81,9 +82,13 @@ module.exports = { }; ``` -The `preprocess` method takes the file contents and filename as arguments, and returns an array of strings to lint. The strings will be linted separately but still be registered to the filename. It's up to the plugin to decide if it needs to return just one part, or multiple pieces. For example in the case of processing `.html` files, you might want to return just one item in the array by combining all scripts, but for `.md` file where each JavaScript block might be independent, you can return multiple items. +**The `preprocess` method** takes the file contents and filename as arguments, and returns an array of code blocks to lint. The code blocks will be linted separately but still be registered to the filename. + +A code block has two properties `text` and `filename`; the `text` property is the content of the block and the `filename` property is the name of the block. ESLint checks the file extension of the `filename` property to know the kind of the code block. If the file extension was included in [`--ext` CLI option](../user-guide/command-line-interface.md#--ext), ESLint resolves `overrides` configs by the `filename` property then lint the code block. Otherwise, ESLint just ignores the code block. + +It's up to the plugin to decide if it needs to return just one part, or multiple pieces. For example in the case of processing `.html` files, you might want to return just one item in the array by combining all scripts, but for `.md` file where each JavaScript block might be independent, you can return multiple items. -The `postprocess` method takes a two-dimensional array of arrays of lint messages and the filename. Each item in the input array corresponds to the part that was returned from the `preprocess` method. The `postprocess` method must adjust the locations of all errors to correspond to locations in the original, unprocessed code, and aggregate them into a single flat array and return it. +**The `postprocess` method** takes a two-dimensional array of arrays of lint messages and the filename. Each item in the input array corresponds to the part that was returned from the `preprocess` method. The `postprocess` method must adjust the locations of all errors to correspond to locations in the original, unprocessed code, and aggregate them into a single flat array and return it. Reported problems have the following location information: @@ -117,6 +122,41 @@ By default, ESLint will not perform autofixes when a processor is used, even whe You can have both rules and processors in a single plugin. You can also have multiple processors in one plugin. To support multiple extensions, add each one to the `processors` element and point them to the same object. +#### Specifying Processor in Config Files + +People use processors by the `processor` key with a processor ID in config files. The processor ID is the concatenated string of a plugin name and a processor name by a slash. And people can use the `overrides` property to tie a file type and a processor. + +For example: + +```yml +plugins: + - a-plugin +overrides: + - files: "*.md" + processor: a-plugin/markdown +``` + +See [Specifying Processor](../user-guide/configuring.md#specifying-processor) for details. + +#### File Extension-named Processor + +If a processor name starts with `.`, ESLint handles the processor as a **file extension-named processor** especially and applies the processor to the kind of files automatically. People don't need to specify the file extension-named processors in their config files. + +For example: + +```js +module.exports = { + processors: { + // This processor will be applied to `*.md` files automatically. + // Also, people can use this processor as "plugin-id/.md" explicitly. + ".md": { + preprocess(text, filename) { /* ... */ }, + postprocess(messageLists, filename) { /* ... */ } + } + } +} +``` + ### Configs in Plugins You can bundle configurations inside a plugin by specifying them under the `configs` key. This can be useful when you want to provide not just code style, but also some custom rules to support it. Multiple configurations are supported per plugin. Note that it is not possible to specify a default configuration for a given plugin and that users must specify in their configuration file when they want to use one. diff --git a/docs/user-guide/configuring.md b/docs/user-guide/configuring.md index ff8021b6459..7cc48c8a418 100644 --- a/docs/user-guide/configuring.md +++ b/docs/user-guide/configuring.md @@ -80,6 +80,55 @@ The following parsers are compatible with ESLint: Note when using a custom parser, the `parserOptions` configuration property is still required for ESLint to work properly with features not in ECMAScript 5 by default. Parsers are all passed `parserOptions` and may or may not use them to determine which features to enable. +## Specifying Processor + +Plugins may provide processors. Processors can extract JavaScript code from another kind of files, then lets ESLint lint the JavaScript code. Or processors can convert JavaScript code in preprocessing for some purpose. + +To specify processors in a configuration file, use the `processor` key with the concatenated string of a plugin name and a processor name by a slash. For example, the following enables the processor `a-processor` that the plugin `a-plugin` provided: + +```json +{ + "plugins": ["a-plugin"], + "processor": "a-plugin/a-processor" +} +``` + +To specify processors for a specific kind of files, use the combination of the `overrides` key and the `processor` key. For example, the following uses the processor `a-plugin/markdown` for `*.md` files. + +```json +{ + "plugins": ["a-plugin"], + "overrides": [ + { + "files": ["*.md"], + "processor": "a-plugin/markdown" + } + ] +} +``` + +Processors may make named code blocks such as `0.js` and `1.js`. ESLint handles such a named code block as like a child file of the original file. You can specify additional configurations for named code blocks by the `overrides` key. For example, the following disables `strict` rule for the named code blocks which end with `.js` in markdown files. + +```json +{ + "plugins": ["a-plugin"], + "overrides": [ + { + "files": ["*.md"], + "processor": "a-plugin/markdown" + }, + { + "files": ["**/*.md/*.js"], + "rules": { + "strict": "off" + } + } + ] +} +``` + +ESLint checks the file extension of named code blocks then ignores those if [`--ext` CLI option](../user-guide/command-line-interface.md#--ext) didn't include the file extension. Be sure to specify the `--ext` option if you wanted to lint named code blocks other than `*.js`. + ## Specifying Environments An environment defines global variables that are predefined. The available environments are: diff --git a/lib/cli-engine.js b/lib/cli-engine.js index b97772133ed..a6158b196bc 100644 --- a/lib/cli-engine.js +++ b/lib/cli-engine.js @@ -201,6 +201,7 @@ function calculateStatsPerRun(results) { * @param {boolean} config.fix If `true` then it does fix. * @param {boolean} config.allowInlineConfig If `true` then it uses directive comments. * @param {boolean} config.reportUnusedDisableDirectives If `true` then it reports unused `eslint-disable` comments. + * @param {RegExp} config.extRegExp The `RegExp` object that tests if a file path has the allowed file extensions. * @param {Linter} config.linter The linter instance to verify. * @returns {LintResult} The result of linting. * @private @@ -213,6 +214,7 @@ function verifyText({ fix, allowInlineConfig, reportUnusedDisableDirectives, + extRegExp, linter }) { const filePath = providedFilePath || ""; @@ -232,7 +234,18 @@ function verifyText({ allowInlineConfig, filename: filePathToVerify, fix, - reportUnusedDisableDirectives + reportUnusedDisableDirectives, + + /** + * Check if the linter should adopt a given code block or not. + * Currently, the linter adopts code blocks if the name matches `--ext` option. + * In the future, `overrides` in the configuration would affect the adoption (https://github.com/eslint/rfcs/pull/20). + * @param {string} blockFilename The virtual filename of a code block. + * @returns {boolean} `true` if the linter should adopt the code block. + */ + filterCodeBlock(blockFilename) { + return extRegExp.test(blockFilename); + } } ); @@ -771,6 +784,7 @@ class CLIEngine { fix, allowInlineConfig, reportUnusedDisableDirectives, + extRegExp: fileEnumerator.extRegExp, linter }); @@ -815,6 +829,7 @@ class CLIEngine { executeOnText(text, filename, warnIgnored) { const { configArrayFactory, + fileEnumerator, ignoredPaths, lastConfigArrays, linter, @@ -858,6 +873,7 @@ class CLIEngine { fix, allowInlineConfig, reportUnusedDisableDirectives, + extRegExp: fileEnumerator.extRegExp, linter })); } diff --git a/lib/cli-engine/config-array-factory.js b/lib/cli-engine/config-array-factory.js index 1ee5902f7b1..9bece1e402d 100644 --- a/lib/cli-engine/config-array-factory.js +++ b/lib/cli-engine/config-array-factory.js @@ -523,7 +523,7 @@ class ConfigArrayFactory { parser: parserName, parserOptions, plugins: pluginList, - processor, // processor is only for file extension processors. + processor, root, rules, settings, diff --git a/lib/cli-engine/file-enumerator.js b/lib/cli-engine/file-enumerator.js index d796a6755b9..5382028b487 100644 --- a/lib/cli-engine/file-enumerator.js +++ b/lib/cli-engine/file-enumerator.js @@ -217,6 +217,14 @@ class FileEnumerator { }); } + /** + * The `RegExp` object that tests if a file path has the allowed file extensions. + * @type {RegExp} + */ + get extRegExp() { + return internalSlotsMap.get(this).extRegExp; + } + /** * Iterate files which are matched by given glob patterns. * @param {string|string[]} patternOrPatterns The glob patterns to iterate files. diff --git a/lib/config/config-validator.js b/lib/config/config-validator.js index d37a50f1c13..b6d145c37b2 100644 --- a/lib/config/config-validator.js +++ b/lib/config/config-validator.js @@ -215,6 +215,19 @@ function validateGlobals(globalsConfig, source = null) { }); } +/** + * Validate `processor` configuration. + * @param {string|undefined} processor The processor name. + * @param {string} source The name of config file. + * @param {function(id:string): Processor} getProcessor The getter of defined processors. + * @returns {void} + */ +function validateProcessor(processor, source, getProcessor) { + if (processor && !getProcessor(processor)) { + throw new Error(`ESLint configuration of processor in '${source}' is invalid: '${processor}' was not found.`); + } +} + /** * Formats an array of schema validation errors. * @param {Array} errors An array of error messages to format. @@ -308,6 +321,7 @@ const validated = new WeakSet(); */ function validateConfigArray(configArray) { const getPluginEnv = Map.prototype.get.bind(configArray.pluginEnvironments); + const getPluginProcessor = Map.prototype.get.bind(configArray.pluginProcessors); const getPluginRule = Map.prototype.get.bind(configArray.pluginRules); // Validate. @@ -319,6 +333,7 @@ function validateConfigArray(configArray) { validateEnvironment(element.env, element.name, getPluginEnv); validateGlobals(element.globals, element.name); + validateProcessor(element.processor, element.name, getPluginProcessor); validateRules(element.rules, element.name, getPluginRule); } } diff --git a/lib/linter.js b/lib/linter.js index f2bb7425932..84640db2178 100644 --- a/lib/linter.js +++ b/lib/linter.js @@ -41,11 +41,13 @@ const commentParser = new ConfigCommentParser(); //------------------------------------------------------------------------------ /** @typedef {InstanceType} ConfigArray */ +/** @typedef {InstanceType} ExtractedConfig */ /** @typedef {import("./util/types").ConfigData} ConfigData */ /** @typedef {import("./util/types").Environment} Environment */ /** @typedef {import("./util/types").GlobalConf} GlobalConf */ /** @typedef {import("./util/types").LintMessage} LintMessage */ /** @typedef {import("./util/types").ParserOptions} ParserOptions */ +/** @typedef {import("./util/types").Processor} Processor */ /** @typedef {import("./util/types").Rule} Rule */ /** @@ -65,6 +67,38 @@ const commentParser = new ConfigCommentParser(); * @property {Rules} ruleMap The loaded rules. */ +/** + * @typedef {Object} VerifyOptions + * @property {boolean} [allowInlineConfig] Allow/disallow inline comments' ability + * to change config once it is set. Defaults to true if not supplied. + * Useful if you want to validate JS without comments overriding rules. + * @property {boolean} [disableFixes] if `true` then the linter doesn't make `fix` + * properties into the lint result. + * @property {string} [filename] the filename of the source code. + * @property {boolean} [reportUnusedDisableDirectives] Adds reported errors for + * unused `eslint-disable` directives. + */ + +/** + * @typedef {Object} ProcessorOptions + * @property {(filename:string, text:string) => boolean} [filterCodeBlock] the + * predicate function that selects adopt code blocks. + * @property {Processor["postprocess"]} [postprocess] postprocessor for report + * messages. If provided, this should accept an array of the message lists + * for each code block returned from the preprocessor, apply a mapping to + * the messages as appropriate, and return a one-dimensional array of + * messages. + * @property {Processor["preprocess"]} [preprocess] preprocessor for source text. + * If provided, this should accept a string of source text, and return an + * array of code blocks to lint. + */ + +/** + * @typedef {Object} FixOptions + * @property {boolean | ((message: LintMessage) => boolean)} [fix] Determines + * whether fixes should be applied. + */ + //------------------------------------------------------------------------------ // Helpers //------------------------------------------------------------------------------ @@ -338,21 +372,37 @@ function findEslintEnv(text) { return retv; } +/** + * Convert "/path/to/" to "". + * `CLIEngine#executeOnText()` method gives "/path/to/" if the filename + * was omitted because `configArray.extractConfig()` requires an absolute path. + * But the linter should pass `` to `RuleContext#getFilename()` in that + * case. + * Also, code blocks can have their virtual filename. If the parent filename was + * ``, the virtual filename is `/0_foo.js` or something like (i.e., + * it's not an absolute path). + * @param {string} filename The filename to normalize. + * @returns {string} The normalized filename. + */ +function normalizeFilename(filename) { + const parts = filename.split(path.sep); + const index = parts.lastIndexOf(""); + + return index === -1 ? filename : parts.slice(index).join(path.sep); +} + /** * Normalizes the possible options for `linter.verify` and `linter.verifyAndFix` to a * consistent shape. - * @param {(string|{reportUnusedDisableDirectives: boolean, filename: string, allowInlineConfig: boolean})} providedOptions Options - * @returns {{reportUnusedDisableDirectives: boolean, filename: string, allowInlineConfig: boolean}} Normalized options + * @param {VerifyOptions} providedOptions Options + * @returns {Required} Normalized options */ function normalizeVerifyOptions(providedOptions) { - const isObjectOptions = typeof providedOptions === "object"; - const providedFilename = isObjectOptions ? providedOptions.filename : providedOptions; - return { - filename: typeof providedFilename === "string" ? providedFilename : "", - allowInlineConfig: !isObjectOptions || providedOptions.allowInlineConfig !== false, - reportUnusedDisableDirectives: isObjectOptions && !!providedOptions.reportUnusedDisableDirectives, - disableFixes: Boolean(providedOptions && providedOptions.disableFixes) + filename: normalizeFilename(providedOptions.filename || ""), + allowInlineConfig: providedOptions.allowInlineConfig !== false, + reportUnusedDisableDirectives: Boolean(providedOptions.reportUnusedDisableDirectives), + disableFixes: Boolean(providedOptions.disableFixes) }; } @@ -780,6 +830,22 @@ function runRules(sourceCode, configuredRules, ruleMapper, parserOptions, parser return lintingProblems; } +/** + * Ensure the source code to be a string. + * @param {string|SourceCode} textOrSourceCode The text or source code object. + * @returns {string} The source code text. + */ +function ensureText(textOrSourceCode) { + if (typeof textOrSourceCode === "object") { + const { hasBOM, text } = textOrSourceCode; + const bom = hasBOM ? "\uFEFF" : ""; + + return bom + text; + } + + return String(textOrSourceCode); +} + /** * Get an environment. * @param {LinterInternalSlots} slots The internal slots of Linter. @@ -849,19 +915,13 @@ class Linter { * Same as linter.verify, except without support for processors. * @param {string|SourceCode} textOrSourceCode The text to parse or a SourceCode object. * @param {ConfigData} providedConfig An ESLintConfig instance to configure everything. - * @param {(string|Object)} [filenameOrOptions] The optional filename of the file being checked. - * If this is not set, the filename will default to '' in the rule context. If - * an object, then it has "filename", "saveState", and "allowInlineConfig" properties. - * @param {boolean} [filenameOrOptions.allowInlineConfig=true] Allow/disallow inline comments' ability to change config once it is set. Defaults to true if not supplied. - * Useful if you want to validate JS without comments overriding rules. - * @param {boolean} [filenameOrOptions.reportUnusedDisableDirectives=false] Adds reported errors for unused - * eslint-disable directives + * @param {VerifyOptions} [providedOptions] The optional filename of the file being checked. * @returns {LintMessage[]} The results as an array of messages or an empty array if no messages. */ - _verifyWithoutProcessors(textOrSourceCode, providedConfig, filenameOrOptions) { + _verifyWithoutProcessors(textOrSourceCode, providedConfig, providedOptions) { const slots = internalSlotsMap.get(this); const config = providedConfig || {}; - const options = normalizeVerifyOptions(filenameOrOptions); + const options = normalizeVerifyOptions(providedOptions); let text; // evaluate arguments @@ -993,91 +1053,120 @@ class Linter { * Verifies the text against the rules specified by the second argument. * @param {string|SourceCode} textOrSourceCode The text to parse or a SourceCode object. * @param {ConfigData|ConfigArray} config An ESLintConfig instance to configure everything. - * @param {(string|Object)} [filenameOrOptions] The optional filename of the file being checked. + * @param {(string|(VerifyOptions&ProcessorOptions))} [filenameOrOptions] The optional filename of the file being checked. * If this is not set, the filename will default to '' in the rule context. If - * an object, then it has "filename", "saveState", and "allowInlineConfig" properties. - * @param {boolean} [filenameOrOptions.allowInlineConfig] Allow/disallow inline comments' ability to change config once it is set. Defaults to true if not supplied. - * Useful if you want to validate JS without comments overriding rules. - * @param {function(string): string[]} [filenameOrOptions.preprocess] preprocessor for source text. If provided, - * this should accept a string of source text, and return an array of code blocks to lint. - * @param {function(Array): LintMessage[]} [filenameOrOptions.postprocess] postprocessor for report messages. If provided, - * this should accept an array of the message lists for each code block returned from the preprocessor, - * apply a mapping to the messages as appropriate, and return a one-dimensional array of messages + * an object, then it has "filename", "allowInlineConfig", and some properties. * @returns {LintMessage[]} The results as an array of messages or an empty array if no messages. */ verify(textOrSourceCode, config, filenameOrOptions) { + debug("Verify"); + const options = typeof filenameOrOptions === "string" + ? { filename: filenameOrOptions } + : filenameOrOptions || {}; + + // CLIEngine passes a `ConfigArray` object. if (config && typeof config.extractConfig === "function") { - return this._verifyWithConfigArray( - textOrSourceCode, - config, - typeof filenameOrOptions === "string" - ? { filename: filenameOrOptions } - : filenameOrOptions || {} - ); + return this._verifyWithConfigArray(textOrSourceCode, config, options); } - const preprocess = filenameOrOptions && filenameOrOptions.preprocess || (rawText => [rawText]); - const postprocess = filenameOrOptions && filenameOrOptions.postprocess || lodash.flatten; - - return postprocess( - preprocess(textOrSourceCode).map( - textBlock => this._verifyWithoutProcessors(textBlock, config, filenameOrOptions) - ) - ); + /* + * `Linter` doesn't support `overrides` property in configuration. + * So we cannot apply multiple processors. + */ + if (options.preprocess || options.postprocess) { + return this._verifyWithProcessor(textOrSourceCode, config, options); + } + return this._verifyWithoutProcessors(textOrSourceCode, config, options); } /** * Verify a given code with `ConfigArray`. - * @param {string} text The source code string. + * @param {string|SourceCode} textOrSourceCode The source code. * @param {ConfigArray} configArray The config array. - * @param {Object} providedOptions The options. + * @param {VerifyOptions&ProcessorOptions} options The options. * @returns {LintMessage[]} The found problems. */ - _verifyWithConfigArray(text, configArray, providedOptions) { - debug("Verify with ConfigArray"); + _verifyWithConfigArray(textOrSourceCode, configArray, options) { + debug("With ConfigArray: %s", options.filename); // Store the config array in order to get plugin envs and rules later. internalSlotsMap.get(this).lastConfigArray = configArray; - /* - * TODO: implement https://github.com/eslint/rfcs/tree/master/designs/2018-processors-improvements here. - */ - // Extract the final config for this file. - const config = configArray.extractConfig(providedOptions.filename); + const config = configArray.extractConfig(options.filename); + const processor = + config.processor && + configArray.pluginProcessors.get(config.processor); - /* - * Convert "/path/to/" to "". - * `CLIEngine#executeOnText()` method gives "/path/to/" if the - * filename was omitted because `configArray.extractConfig()` requires - * an absolute path. But linter should pass `` to - * `RuleContext#getFilename()` in that case. - */ - const filename = path.basename(providedOptions.filename) === "" - ? "" - : providedOptions.filename; - - // Make options. - const options = { - ...providedOptions, - filename - }; + // Verify. + if (processor) { + debug("Apply the processor: %o", config.processor); + const { preprocess, postprocess, supportsAutofix } = processor; + const disableFixes = options.disableFixes || !supportsAutofix; - // Apply processor. - if (config.processor) { - const processor = configArray.pluginProcessors.get(config.processor); + return this._verifyWithProcessor( + textOrSourceCode, + config, + { ...options, disableFixes, postprocess, preprocess }, + configArray + ); + } + return this._verifyWithoutProcessors(textOrSourceCode, config, options); + } - options.preprocess = processor.preprocess; - options.postprocess = processor.postprocess; - if (!processor.supportsAutofix) { + /** + * Verify with a processor. + * @param {string|SourceCode} textOrSourceCode The source code. + * @param {ConfigData|ExtractedConfig} config The config array. + * @param {VerifyOptions&ProcessorOptions} options The options. + * @param {ConfigArray} [configForRecursive] The `CofnigArray` object to apply multiple processors recursively. + * @returns {LintMessage[]} The found problems. + */ + _verifyWithProcessor(textOrSourceCode, config, options, configForRecursive) { + const filename = options.filename || ""; + const text = ensureText(textOrSourceCode); + const preprocess = options.preprocess || (rawText => [rawText]); + const postprocess = options.postprocess || lodash.flatten; + const filterCodeBlock = + options.filterCodeBlock || + (blockFilename => blockFilename.endsWith(".js")); + const originalExtname = path.extname(filename); + const messageLists = preprocess(text, filename).map((block, i) => { + debug("A code block was found: %o", block.filename || "(unnamed)"); + + // Keep the legacy behavior. + if (typeof block === "string") { + return this._verifyWithoutProcessors(block, config, options); + } + + const blockText = block.text; + const blockName = path.join(filename, `${i}_${block.filename}`); - // Use `disableFixes` of https://github.com/eslint/rfcs/tree/master/designs/2018-processors-improvements - options.disableFixes = true; + // Skip this block if filtered. + if (!filterCodeBlock(blockName, blockText)) { + debug("This code block was skipped."); + return []; } - } - // Verify. - return this.verify(text, config, options); + // Resolve configuration again if the file extension was changed. + if (configForRecursive && path.extname(blockName) !== originalExtname) { + debug("Resolving configuration again because the file extension was changed."); + return this._verifyWithConfigArray( + blockText, + configForRecursive, + { ...options, filename: blockName } + ); + } + + // Does lint. + return this._verifyWithoutProcessors( + blockText, + config, + { ...options, filename: blockName } + ); + }); + + return postprocess(messageLists, filename); } /** @@ -1140,16 +1229,7 @@ class Linter { * have been applied. * @param {string} text The source text to apply fixes to. * @param {ConfigData|ConfigArray} config The ESLint config object to use. - * @param {Object} options The ESLint options object to use. - * @param {string} options.filename The filename from which the text was read. - * @param {boolean} options.allowInlineConfig Flag indicating if inline comments - * should be allowed. - * @param {boolean|Function} options.fix Determines whether fixes should be applied - * @param {Function} options.preprocess preprocessor for source text. If provided, this should - * accept a string of source text, and return an array of code blocks to lint. - * @param {Function} options.postprocess postprocessor for report messages. If provided, - * this should accept an array of the message lists for each code block returned from the preprocessor, - * apply a mapping to the messages as appropriate, and return a one-dimensional array of messages + * @param {VerifyOptions&ProcessorOptions&FixOptions} options The ESLint options object to use. * @returns {{fixed:boolean,messages:LintMessage[],output:string}} The result of the fix operation as returned from the * SourceCodeFixer. */ diff --git a/tests/fixtures/processors/pattern-processor.js b/tests/fixtures/processors/pattern-processor.js new file mode 100644 index 00000000000..462c591edab --- /dev/null +++ b/tests/fixtures/processors/pattern-processor.js @@ -0,0 +1,63 @@ +"use strict"; + +/** + * Define a processor which extract code blocks `pattern` regexp matched. + * The defined processor supports autofix, but doesn't have `supportsAutofix` property. + * + * @param {RegExp} pattern The regular expression pattern for code blocks. + * The first capture group becomes the file extension of the code block. + * The second capture group becomes the text of the code block. + * @returns {Processor} The defined processor. + */ +exports.defineProcessor = (pattern, legacy = false) => { + const blocksMap = new Map(); + + return { + preprocess(wholeCode, filename) { + const blocks = []; + blocksMap.set(filename, blocks); + + // Extract code blocks. + let match; + while ((match = pattern.exec(wholeCode)) !== null) { + const [codeBlock, ext, text] = match; + const filename = `${blocks.length}.${ext}`; + const offset = match.index + codeBlock.indexOf(text); + const lineOffset = wholeCode.slice(0, match.index).split("\n").length; + + blocks.push({ text, filename, lineOffset, offset }); + } + + if (legacy) { + return blocks.map(b => b.text); + } + return blocks; + }, + + postprocess(messageLists, filename) { + const blocks = blocksMap.get(filename); + blocksMap.delete(filename); + + // Fix the location of reports. + if (blocks) { + for (let i = 0; i < messageLists.length; ++i) { + const messages = messageLists[i]; + const { lineOffset, offset } = blocks[i]; + + for (const message of messages) { + message.line += lineOffset; + if (message.endLine != null) { + message.endLine += lineOffset; + } + if (message.fix != null) { + message.fix.range[0] += offset; + message.fix.range[1] += offset; + } + } + } + } + + return [].concat(...messageLists); + } + }; +}; diff --git a/tests/lib/cli-engine.js b/tests/lib/cli-engine.js index 2e609a65061..42ccc5e27f9 100644 --- a/tests/lib/cli-engine.js +++ b/tests/lib/cli-engine.js @@ -21,7 +21,6 @@ const assert = require("chai").assert, { defineCLIEngineWithInMemoryFileSystem } = require("./cli-engine/_utils"); const proxyquire = require("proxyquire").noCallThru().noPreserveCache(); - const fCache = require("file-entry-cache"); //------------------------------------------------------------------------------ @@ -3013,6 +3012,320 @@ describe("CLIEngine", () => { assert.deepStrictEqual(results[0].output, "fixed;"); }); }); + + describe("multiple processors", () => { + + /** + * Unindent template strings. + * @param {string[]} strings Strings. + * @param {...any} values Values. + * @returns {string} Unindented string. + */ + function unindent(strings, ...values) { + const text = strings + .map((s, i) => (i === 0 ? s : values[i - 1] + s)) + .join(""); + const lines = text.split("\n").filter(Boolean); + const indentLen = /[^ ]/u.exec(lines[0]).index; + + return lines + .map(line => line.slice(indentLen)) + .join("\n"); + } + + const root = path.join(os.tmpdir(), "eslint/cli-engine/multiple-processors"); + const commonFiles = { + "node_modules/pattern-processor/index.js": fs.readFileSync( + require.resolve("../fixtures/processors/pattern-processor"), + "utf8" + ), + "node_modules/eslint-plugin-markdown/index.js": ` + const { defineProcessor } = require("pattern-processor"); + const processor = defineProcessor(${/```(\w+)\n([\s\S]+?)\n```/gu}); + exports.processors = { + ".md": { ...processor, supportsAutofix: true }, + "non-fixable": processor + }; + `, + "node_modules/eslint-plugin-html/index.js": ` + const { defineProcessor } = require("pattern-processor"); + const processor = defineProcessor(${/ + + \`\`\` + ` + }; + + it("should lint only JavaScript blocks if '--ext' was not given.", () => { + CLIEngine = defineCLIEngineWithInMemoryFileSystem({ + cwd: () => root, + files: { + ...commonFiles, + ".eslintrc.json": JSON.stringify({ + plugins: ["markdown", "html"], + rules: { semi: "error" } + }) + } + }).CLIEngine; + engine = new CLIEngine({ cwd: root }); + + const { results } = engine.executeOnFiles(["test.md"]); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].messages.length, 1); + assert.strictEqual(results[0].messages[0].ruleId, "semi"); + assert.strictEqual(results[0].messages[0].line, 2); + }); + + it("should fix only JavaScript blocks if '--ext' was not given.", () => { + CLIEngine = defineCLIEngineWithInMemoryFileSystem({ + cwd: () => root, + files: { + ...commonFiles, + ".eslintrc.json": JSON.stringify({ + plugins: ["markdown", "html"], + rules: { semi: "error" } + }) + } + }).CLIEngine; + engine = new CLIEngine({ cwd: root, fix: true }); + + const { results } = engine.executeOnFiles(["test.md"]); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].messages.length, 0); + assert.strictEqual(results[0].output, unindent` + \`\`\`js + console.log("hello");${/* ← fixed */""} + \`\`\` + \`\`\`html +
Hello
+ + + \`\`\` + `); + }); + + it("should lint HTML blocks as well with multiple processors if '--ext' option was given.", () => { + CLIEngine = defineCLIEngineWithInMemoryFileSystem({ + cwd: () => root, + files: { + ...commonFiles, + ".eslintrc.json": JSON.stringify({ + plugins: ["markdown", "html"], + rules: { semi: "error" } + }) + } + }).CLIEngine; + engine = new CLIEngine({ cwd: root, extensions: ["js", "html"] }); + + const { results } = engine.executeOnFiles(["test.md"]); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].messages.length, 2); + assert.strictEqual(results[0].messages[0].ruleId, "semi"); // JS block + assert.strictEqual(results[0].messages[0].line, 2); + assert.strictEqual(results[0].messages[1].ruleId, "semi"); // JS block in HTML block + assert.strictEqual(results[0].messages[1].line, 7); + }); + + it("should fix HTML blocks as well with multiple processors if '--ext' option was given.", () => { + CLIEngine = defineCLIEngineWithInMemoryFileSystem({ + cwd: () => root, + files: { + ...commonFiles, + ".eslintrc.json": JSON.stringify({ + plugins: ["markdown", "html"], + rules: { semi: "error" } + }) + } + }).CLIEngine; + engine = new CLIEngine({ cwd: root, extensions: ["js", "html"], fix: true }); + + const { results } = engine.executeOnFiles(["test.md"]); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].messages.length, 0); + assert.strictEqual(results[0].output, unindent` + \`\`\`js + console.log("hello");${/* ← fixed */""} + \`\`\` + \`\`\`html +
Hello
+ + + \`\`\` + `); + }); + + it("should use overriden processor; should report HTML blocks but not fix HTML blocks if the processor for '*.html' didn't support autofix.", () => { + CLIEngine = defineCLIEngineWithInMemoryFileSystem({ + cwd: () => root, + files: { + ...commonFiles, + ".eslintrc.json": JSON.stringify({ + plugins: ["markdown", "html"], + rules: { semi: "error" }, + overrides: [ + { + files: "*.html", + processor: "html/non-fixable" // supportsAutofix: false + } + ] + }) + } + }).CLIEngine; + engine = new CLIEngine({ cwd: root, extensions: ["js", "html"], fix: true }); + + const { results } = engine.executeOnFiles(["test.md"]); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].messages.length, 1); + assert.strictEqual(results[0].messages[0].ruleId, "semi"); // JS Block in HTML Block + assert.strictEqual(results[0].messages[0].line, 7); + assert.strictEqual(results[0].messages[0].fix, void 0); + assert.strictEqual(results[0].output, unindent` + \`\`\`js + console.log("hello");${/* ← fixed */""} + \`\`\` + \`\`\`html +
Hello
+ + + \`\`\` + `); + }); + + it("should use the config '**/*.html/*.js' to lint JavaScript blocks in HTML.", () => { + CLIEngine = defineCLIEngineWithInMemoryFileSystem({ + cwd: () => root, + files: { + ...commonFiles, + ".eslintrc.json": JSON.stringify({ + plugins: ["markdown", "html"], + rules: { semi: "error" }, + overrides: [ + { + files: "*.html", + + // this rules are not used because ESLint re-resolve configs if a code block had a different file extension. + rules: { + semi: "error", + "no-console": "off" + } + }, + { + files: "**/*.html/*.js", + rules: { + semi: "off", + "no-console": "error" + } + } + ] + }) + } + }).CLIEngine; + engine = new CLIEngine({ cwd: root, extensions: ["js", "html"] }); + + const { results } = engine.executeOnFiles(["test.md"]); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].messages.length, 2); + assert.strictEqual(results[0].messages[0].ruleId, "semi"); + assert.strictEqual(results[0].messages[0].line, 2); + assert.strictEqual(results[0].messages[1].ruleId, "no-console"); + assert.strictEqual(results[0].messages[1].line, 7); + }); + + it("should use the same config as one which has 'processor' property in order to lint blocks in HTML if the processor was legacy style.", () => { + CLIEngine = defineCLIEngineWithInMemoryFileSystem({ + cwd: () => root, + files: { + ...commonFiles, + ".eslintrc.json": JSON.stringify({ + plugins: ["markdown", "html"], + rules: { semi: "error" }, + overrides: [ + { + files: "*.html", + processor: "html/legacy", // this processor returns strings rather than `{text, filename}` + rules: { + semi: "off", + "no-console": "error" + } + }, + { + files: "**/*.html/*.js", + rules: { + semi: "error", + "no-console": "off" + } + } + ] + }) + } + }).CLIEngine; + engine = new CLIEngine({ cwd: root, extensions: ["js", "html"] }); + + const { results } = engine.executeOnFiles(["test.md"]); + + assert.strictEqual(results.length, 1); + assert.strictEqual(results[0].messages.length, 3); + assert.strictEqual(results[0].messages[0].ruleId, "semi"); + assert.strictEqual(results[0].messages[0].line, 2); + assert.strictEqual(results[0].messages[1].ruleId, "no-console"); + assert.strictEqual(results[0].messages[1].line, 7); + assert.strictEqual(results[0].messages[2].ruleId, "no-console"); + assert.strictEqual(results[0].messages[2].line, 10); + }); + + it("should throw an error if invalid processor was specified.", () => { + CLIEngine = defineCLIEngineWithInMemoryFileSystem({ + cwd: () => root, + files: { + ...commonFiles, + ".eslintrc.json": JSON.stringify({ + plugins: ["markdown", "html"], + processor: "markdown/unknown" + }) + } + }).CLIEngine; + engine = new CLIEngine({ cwd: root }); + + assert.throws(() => { + engine.executeOnFiles(["test.md"]); + }, /ESLint configuration of processor in '\.eslintrc\.json' is invalid: 'markdown\/unknown' was not found\./u); + }); + }); }); describe("getConfigForFile", () => { diff --git a/tests/lib/linter.js b/tests/lib/linter.js index ac952667fac..bf88d00412b 100644 --- a/tests/lib/linter.js +++ b/tests/lib/linter.js @@ -73,7 +73,10 @@ const ESLINT_ENV = "eslint-env"; describe("Linter", () => { const filename = "filename.js"; - let sandbox, linter; + let sandbox; + + /** @type {InstanceType} */ + let linter; beforeEach(() => { linter = new Linter(); @@ -4191,17 +4194,31 @@ describe("Linter", () => { }); describe("processors", () => { + let receivedFilenames = []; + beforeEach(() => { + receivedFilenames = []; // A rule that always reports the AST with a message equal to the source text linter.defineRule("report-original-text", context => ({ Program(ast) { + receivedFilenames.push(context.getFilename()); context.report({ node: ast, message: context.getSourceCode().text }); } })); }); describe("preprocessors", () => { + it("should be received text and filename.", () => { + const code = "foo bar baz"; + const preprocess = sinon.spy(text => text.split(" ")); + + linter.verify(code, {}, { filename, preprocess }); + + assert.strictEqual(preprocess.calledOnce, true); + assert.deepStrictEqual(preprocess.args[0], [code, filename]); + }); + it("should apply a preprocessor to the code, and lint each code sample separately", () => { const code = "foo bar baz"; const problems = linter.verify( @@ -4211,8 +4228,6 @@ describe("Linter", () => { // Apply a preprocessor that splits the source text into spaces and lints each word individually preprocess(input) { - assert.strictEqual(input, code); - assert.strictEqual(arguments.length, 1); return input.split(" "); } } @@ -4221,9 +4236,46 @@ describe("Linter", () => { assert.strictEqual(problems.length, 3); assert.deepStrictEqual(problems.map(problem => problem.message), ["foo", "bar", "baz"]); }); + + it("should apply a preprocessor to the code even if the preprocessor returned code block objects.", () => { + const code = "foo bar baz"; + const problems = linter.verify( + code, + { rules: { "report-original-text": "error" } }, + { + filename, + + // Apply a preprocessor that splits the source text into spaces and lints each word individually + preprocess(input) { + return input.split(" ").map(text => ({ + filename: "block.js", + text + })); + } + } + ); + + assert.strictEqual(problems.length, 3); + assert.deepStrictEqual(problems.map(problem => problem.message), ["foo", "bar", "baz"]); + assert.strictEqual(receivedFilenames.length, 3); + assert(/^filename\.js[/\\]0_block\.js/u.test(receivedFilenames[0])); + assert(/^filename\.js[/\\]1_block\.js/u.test(receivedFilenames[1])); + assert(/^filename\.js[/\\]2_block\.js/u.test(receivedFilenames[2])); + }); }); describe("postprocessors", () => { + it("should be received result and filename.", () => { + const code = "foo bar baz"; + const preprocess = sinon.spy(text => text.split(" ")); + const postprocess = sinon.spy(text => [text]); + + linter.verify(code, {}, { filename, postprocess, preprocess }); + + assert.strictEqual(postprocess.calledOnce, true); + assert.deepStrictEqual(postprocess.args[0], [[[], [], []], filename]); + }); + it("should apply a postprocessor to the reported messages", () => { const code = "foo bar baz"; @@ -4238,9 +4290,6 @@ describe("Linter", () => { * to make sure they correspond to the locations in the original text. */ postprocess(problemLists) { - assert.strictEqual(problemLists.length, 3); - assert.strictEqual(arguments.length, 1); - problemLists.forEach(problemList => assert.strictEqual(problemList.length, 1)); return problemLists.reduce( (combinedList, problemList, index) =>