diff --git a/lib/__tests__/postcssPlugin.test.js b/lib/__tests__/postcssPlugin.test.js index 6207e4dfc1..f7af3c3959 100644 --- a/lib/__tests__/postcssPlugin.test.js +++ b/lib/__tests__/postcssPlugin.test.js @@ -68,11 +68,16 @@ it("`configFile` option with undefined rule", () => { return postcssPlugin .process("a {}", { from: undefined }, config) - .then(() => { - throw new Error("should not have succeeded"); - }) - .catch(err => { - expect(err).toEqual(configurationError(`Undefined rule ${ruleName}`)); + .then(result => { + expect(result.messages).toContainEqual( + expect.objectContaining({ + line: 1, + column: 1, + rule: ruleName, + text: `Unknown rule ${ruleName}.`, + severity: "error" + }) + ); }); }); diff --git a/lib/__tests__/reportUnknownRuleNames.test.js b/lib/__tests__/reportUnknownRuleNames.test.js new file mode 100644 index 0000000000..b3199865dd --- /dev/null +++ b/lib/__tests__/reportUnknownRuleNames.test.js @@ -0,0 +1,62 @@ +"use strict"; + +const standalone = require("../standalone"); + +it("test case (1)", () => { + const config = { + rules: { + "color-hex-cas": ["upper"], + "function-whitelst": ["scale"] + } + }; + + return standalone({ + config, + code: "a {}" + }).then(linted => { + expect(linted.results[0].warnings).toHaveLength(2); + expect(linted.results[0].warnings).toContainEqual({ + line: 1, + column: 1, + severity: "error", + rule: "color-hex-cas", + text: "Unknown rule color-hex-cas. Did you mean color-hex-case?" + }); + expect(linted.results[0].warnings).toContainEqual({ + line: 1, + column: 1, + severity: "error", + rule: "function-whitelst", + text: "Unknown rule function-whitelst. Did you mean function-whitelist?" + }); + }); +}); + +it("test case (2)", () => { + const config = { + rules: { + "color-hex-case": ["upper"], + "function-whitelst": ["rgb"] + } + }; + + return standalone({ + config, + code: "a { color: #fff; transform: scale(0.7); }" + }).then(linted => { + expect(linted.results[0].warnings).toHaveLength(2); + expect(linted.results[0].warnings).toContainEqual({ + line: 1, + column: 1, + severity: "error", + rule: "function-whitelst", + text: "Unknown rule function-whitelst. Did you mean function-whitelist?" + }); + expect(linted.results[0].warnings).toContainEqual( + expect.objectContaining({ + severity: "error", + rule: "color-hex-case" + }) + ); + }); +}); diff --git a/lib/augmentConfig.js b/lib/augmentConfig.js index 76c657bc47..2260d504c9 100644 --- a/lib/augmentConfig.js +++ b/lib/augmentConfig.js @@ -320,15 +320,16 @@ function normalizeAllRuleSettings( requireRule(ruleName) || _.get(config, ["pluginFunctions", ruleName]); if (!rule) { - throw configurationError(`Undefined rule ${ruleName}`); + normalizedRules[ruleName] = []; + } else { + normalizedRules[ruleName] = normalizeRuleSettings( + rawRuleSettings, + ruleName, + _.get(rule, "primaryOptionArray") + ); } - - normalizedRules[ruleName] = normalizeRuleSettings( - rawRuleSettings, - ruleName, - _.get(rule, "primaryOptionArray") - ); }); + config.rules = normalizedRules; return config; diff --git a/lib/lintSource.js b/lib/lintSource.js index 548a217dfa..46345898d6 100644 --- a/lib/lintSource.js +++ b/lib/lintSource.js @@ -3,9 +3,9 @@ const _ = require("lodash"); const assignDisabledRanges = require("./assignDisabledRanges"); -const configurationError = require("./utils/configurationError"); const getOsEol = require("./utils/getOsEol"); const path = require("path"); +const reportUnknownRuleNames = require("./reportUnknownRuleNames"); const requireRule = require("./requireRule"); const rulesOrder = require("./rules"); @@ -205,7 +205,15 @@ function lintPostcssResult( requireRule(ruleName) || _.get(config, ["pluginFunctions", ruleName]); if (ruleFunction === undefined) { - throw configurationError(`Undefined rule ${ruleName}`); + performRules.push( + Promise.all( + postcssRoots.map(postcssRoot => + reportUnknownRuleNames(ruleName, postcssRoot, postcssResult) + ) + ) + ); + + return; } const ruleSettings = _.get(config, ["rules", ruleName]); diff --git a/lib/reportUnknownRuleNames.js b/lib/reportUnknownRuleNames.js new file mode 100644 index 0000000000..dd6f86a4e1 --- /dev/null +++ b/lib/reportUnknownRuleNames.js @@ -0,0 +1,71 @@ +"use strict"; + +const levenshteinDistance = require("./utils/levenshteinDistance"); +const rules = require("./rules"); + +const MAX_LEVENSHTEIN_DISTANCE = 6; + +function extractSuggestions(ruleName) { + const suggestions = new Array(MAX_LEVENSHTEIN_DISTANCE); + + for (let i = 0; i < suggestions.length; i++) { + suggestions[i] = []; + } + + rules.forEach(existRuleName => { + const distance = levenshteinDistance( + existRuleName, + ruleName, + MAX_LEVENSHTEIN_DISTANCE + ); + + if (distance > 0) { + suggestions[distance - 1].push(existRuleName); + } + }); + + let result = []; + + for (let i = 0; i < suggestions.length; i++) { + if (suggestions[i].length > 0) { + if (i < 3) { + return suggestions[i].slice(0, 3); + } + + result = result.concat(suggestions[i]); + } + } + + return result.slice(0, 3); +} + +function rejectMessage(ruleName, suggestions = []) { + return ( + `Unknown rule ${ruleName}.` + + (suggestions.length > 0 ? ` Did you mean ${suggestions.join(", ")}?` : "") + ); +} + +const cache = new Map(); + +module.exports = function reportUnknownRuleNames( + unknownRuleName, + postcssRoot, + postcssResult +) { + const suggestions = cache.has(unknownRuleName) + ? cache.get(unknownRuleName) + : extractSuggestions(unknownRuleName); + const warningProperties = { + severity: "error", + rule: unknownRuleName, + node: postcssRoot, + index: 0 + }; + + cache.set(unknownRuleName, suggestions); + postcssResult.warn( + rejectMessage(unknownRuleName, suggestions), + warningProperties + ); +}; diff --git a/lib/utils/__tests__/levenshteinDistance.test.js b/lib/utils/__tests__/levenshteinDistance.test.js new file mode 100644 index 0000000000..e8318f7112 --- /dev/null +++ b/lib/utils/__tests__/levenshteinDistance.test.js @@ -0,0 +1,36 @@ +"use strict"; + +const levenshteinDistance = require("../levenshteinDistance"); + +it("return -1 if max distance exited", () => { + expect(levenshteinDistance("aaaaa aaaaa", "aaaaa", 5)).toBe(-1); + expect(levenshteinDistance("a", "a", 0)).toBe(0); + expect(levenshteinDistance("a", "aa", 0)).toBe(-1); +}); + +it("same strings return 0", () => { + expect(levenshteinDistance("", "")).toBe(0); + expect(levenshteinDistance("a", "a")).toBe(0); + expect(levenshteinDistance("aaaa", "aaaa")).toBe(0); + expect(levenshteinDistance("aa aa", "aa aa")).toBe(0); +}); + +it("common test cases", () => { + expect(levenshteinDistance("", "function")).toBe(8); + expect(levenshteinDistance("function", "")).toBe(8); + expect(levenshteinDistance("funcsion", "function")).toBe(1); + expect(levenshteinDistance("funtion", "function")).toBe(1); + expect(levenshteinDistance("function-a", "a-function")).toBe(4); + expect(levenshteinDistance("allow", "allows")).toBe(1); + expect(levenshteinDistance("block-no-empty", "block-emty-no")).toBe(7); + expect(levenshteinDistance("comments", "coment")).toBe(2); +}); + +it("max distance works properly", () => { + expect(levenshteinDistance("blabla", "albalb", 4)).toBe(4); + expect(levenshteinDistance("comments", "comment", 1)).toBe(1); + expect(levenshteinDistance("comments", "coment", 1)).toBe(-1); + expect( + levenshteinDistance("duplicate-no-sorce", "no-duplicate-source", 3) + ).toBe(-1); +}); diff --git a/lib/utils/levenshteinDistance.js b/lib/utils/levenshteinDistance.js new file mode 100644 index 0000000000..e49557ee2e --- /dev/null +++ b/lib/utils/levenshteinDistance.js @@ -0,0 +1,51 @@ +/* @flow */ +"use strict"; + +/** + * Compute levenshtein distance, see https://en.wikipedia.org/wiki/Levenshtein_distance for more info + * @param {string} firstStr first string + * @param {string} secondStr second string + * @param {number} [maxDistance=99] maximum distance, if distance is greater then returns -1 + * @returns {number} computed distance + */ +module.exports = function levenshteinDistance( + firstStr /*: string*/, + secondStr /*: string*/, + maxDistance /*: number*/ = 99 +) /*: number*/ { + if (Math.abs(firstStr.length - secondStr.length) > maxDistance) return -1; + + if (firstStr.length === 0 || secondStr.length === 0) + return firstStr.length || secondStr.length; + + let line = new Uint32Array(firstStr.length + 1); + + for (let i = 0; i < line.length; i++) { + line[i] = i; + } + + for (let i = 0; i < secondStr.length; i++) { + const newLine = line.slice(); + + newLine[0] = i + 1; + let min = newLine[0]; + + for (let j = 1; j < line.length; j++) { + newLine[j] = Math.min( + line[j - 1] + (secondStr[i] === firstStr[j - 1] ? 0 : 1), + line[j] + 1, + newLine[j - 1] + 1 + ); + + if (newLine[j] < min) min = newLine[j]; + } + + if (min > maxDistance) { + return -1; + } + + line = newLine; + } + + return line[line.length - 1] > maxDistance ? -1 : line[line.length - 1]; +};