[d565449] | 1 | /**
|
---|
| 2 | * @fileoverview A helper that translates context.report() calls from the rule API into generic problem objects
|
---|
| 3 | * @author Teddy Katz
|
---|
| 4 | */
|
---|
| 5 |
|
---|
| 6 | "use strict";
|
---|
| 7 |
|
---|
| 8 | //------------------------------------------------------------------------------
|
---|
| 9 | // Requirements
|
---|
| 10 | //------------------------------------------------------------------------------
|
---|
| 11 |
|
---|
| 12 | const assert = require("assert");
|
---|
| 13 | const ruleFixer = require("./rule-fixer");
|
---|
| 14 | const interpolate = require("./interpolate");
|
---|
| 15 |
|
---|
| 16 | //------------------------------------------------------------------------------
|
---|
| 17 | // Typedefs
|
---|
| 18 | //------------------------------------------------------------------------------
|
---|
| 19 |
|
---|
| 20 | /** @typedef {import("../shared/types").LintMessage} LintMessage */
|
---|
| 21 |
|
---|
| 22 | /**
|
---|
| 23 | * An error message description
|
---|
| 24 | * @typedef {Object} MessageDescriptor
|
---|
| 25 | * @property {ASTNode} [node] The reported node
|
---|
| 26 | * @property {Location} loc The location of the problem.
|
---|
| 27 | * @property {string} message The problem message.
|
---|
| 28 | * @property {Object} [data] Optional data to use to fill in placeholders in the
|
---|
| 29 | * message.
|
---|
| 30 | * @property {Function} [fix] The function to call that creates a fix command.
|
---|
| 31 | * @property {Array<{desc?: string, messageId?: string, fix: Function}>} suggest Suggestion descriptions and functions to create a the associated fixes.
|
---|
| 32 | */
|
---|
| 33 |
|
---|
| 34 | //------------------------------------------------------------------------------
|
---|
| 35 | // Module Definition
|
---|
| 36 | //------------------------------------------------------------------------------
|
---|
| 37 |
|
---|
| 38 |
|
---|
| 39 | /**
|
---|
| 40 | * Translates a multi-argument context.report() call into a single object argument call
|
---|
| 41 | * @param {...*} args A list of arguments passed to `context.report`
|
---|
| 42 | * @returns {MessageDescriptor} A normalized object containing report information
|
---|
| 43 | */
|
---|
| 44 | function normalizeMultiArgReportCall(...args) {
|
---|
| 45 |
|
---|
| 46 | // If there is one argument, it is considered to be a new-style call already.
|
---|
| 47 | if (args.length === 1) {
|
---|
| 48 |
|
---|
| 49 | // Shallow clone the object to avoid surprises if reusing the descriptor
|
---|
| 50 | return Object.assign({}, args[0]);
|
---|
| 51 | }
|
---|
| 52 |
|
---|
| 53 | // If the second argument is a string, the arguments are interpreted as [node, message, data, fix].
|
---|
| 54 | if (typeof args[1] === "string") {
|
---|
| 55 | return {
|
---|
| 56 | node: args[0],
|
---|
| 57 | message: args[1],
|
---|
| 58 | data: args[2],
|
---|
| 59 | fix: args[3]
|
---|
| 60 | };
|
---|
| 61 | }
|
---|
| 62 |
|
---|
| 63 | // Otherwise, the arguments are interpreted as [node, loc, message, data, fix].
|
---|
| 64 | return {
|
---|
| 65 | node: args[0],
|
---|
| 66 | loc: args[1],
|
---|
| 67 | message: args[2],
|
---|
| 68 | data: args[3],
|
---|
| 69 | fix: args[4]
|
---|
| 70 | };
|
---|
| 71 | }
|
---|
| 72 |
|
---|
| 73 | /**
|
---|
| 74 | * Asserts that either a loc or a node was provided, and the node is valid if it was provided.
|
---|
| 75 | * @param {MessageDescriptor} descriptor A descriptor to validate
|
---|
| 76 | * @returns {void}
|
---|
| 77 | * @throws AssertionError if neither a node nor a loc was provided, or if the node is not an object
|
---|
| 78 | */
|
---|
| 79 | function assertValidNodeInfo(descriptor) {
|
---|
| 80 | if (descriptor.node) {
|
---|
| 81 | assert(typeof descriptor.node === "object", "Node must be an object");
|
---|
| 82 | } else {
|
---|
| 83 | assert(descriptor.loc, "Node must be provided when reporting error if location is not provided");
|
---|
| 84 | }
|
---|
| 85 | }
|
---|
| 86 |
|
---|
| 87 | /**
|
---|
| 88 | * Normalizes a MessageDescriptor to always have a `loc` with `start` and `end` properties
|
---|
| 89 | * @param {MessageDescriptor} descriptor A descriptor for the report from a rule.
|
---|
| 90 | * @returns {{start: Location, end: (Location|null)}} An updated location that infers the `start` and `end` properties
|
---|
| 91 | * from the `node` of the original descriptor, or infers the `start` from the `loc` of the original descriptor.
|
---|
| 92 | */
|
---|
| 93 | function normalizeReportLoc(descriptor) {
|
---|
| 94 | if (descriptor.loc) {
|
---|
| 95 | if (descriptor.loc.start) {
|
---|
| 96 | return descriptor.loc;
|
---|
| 97 | }
|
---|
| 98 | return { start: descriptor.loc, end: null };
|
---|
| 99 | }
|
---|
| 100 | return descriptor.node.loc;
|
---|
| 101 | }
|
---|
| 102 |
|
---|
| 103 | /**
|
---|
| 104 | * Clones the given fix object.
|
---|
| 105 | * @param {Fix|null} fix The fix to clone.
|
---|
| 106 | * @returns {Fix|null} Deep cloned fix object or `null` if `null` or `undefined` was passed in.
|
---|
| 107 | */
|
---|
| 108 | function cloneFix(fix) {
|
---|
| 109 | if (!fix) {
|
---|
| 110 | return null;
|
---|
| 111 | }
|
---|
| 112 |
|
---|
| 113 | return {
|
---|
| 114 | range: [fix.range[0], fix.range[1]],
|
---|
| 115 | text: fix.text
|
---|
| 116 | };
|
---|
| 117 | }
|
---|
| 118 |
|
---|
| 119 | /**
|
---|
| 120 | * Check that a fix has a valid range.
|
---|
| 121 | * @param {Fix|null} fix The fix to validate.
|
---|
| 122 | * @returns {void}
|
---|
| 123 | */
|
---|
| 124 | function assertValidFix(fix) {
|
---|
| 125 | if (fix) {
|
---|
| 126 | assert(fix.range && typeof fix.range[0] === "number" && typeof fix.range[1] === "number", `Fix has invalid range: ${JSON.stringify(fix, null, 2)}`);
|
---|
| 127 | }
|
---|
| 128 | }
|
---|
| 129 |
|
---|
| 130 | /**
|
---|
| 131 | * Compares items in a fixes array by range.
|
---|
| 132 | * @param {Fix} a The first message.
|
---|
| 133 | * @param {Fix} b The second message.
|
---|
| 134 | * @returns {int} -1 if a comes before b, 1 if a comes after b, 0 if equal.
|
---|
| 135 | * @private
|
---|
| 136 | */
|
---|
| 137 | function compareFixesByRange(a, b) {
|
---|
| 138 | return a.range[0] - b.range[0] || a.range[1] - b.range[1];
|
---|
| 139 | }
|
---|
| 140 |
|
---|
| 141 | /**
|
---|
| 142 | * Merges the given fixes array into one.
|
---|
| 143 | * @param {Fix[]} fixes The fixes to merge.
|
---|
| 144 | * @param {SourceCode} sourceCode The source code object to get the text between fixes.
|
---|
| 145 | * @returns {{text: string, range: number[]}} The merged fixes
|
---|
| 146 | */
|
---|
| 147 | function mergeFixes(fixes, sourceCode) {
|
---|
| 148 | for (const fix of fixes) {
|
---|
| 149 | assertValidFix(fix);
|
---|
| 150 | }
|
---|
| 151 |
|
---|
| 152 | if (fixes.length === 0) {
|
---|
| 153 | return null;
|
---|
| 154 | }
|
---|
| 155 | if (fixes.length === 1) {
|
---|
| 156 | return cloneFix(fixes[0]);
|
---|
| 157 | }
|
---|
| 158 |
|
---|
| 159 | fixes.sort(compareFixesByRange);
|
---|
| 160 |
|
---|
| 161 | const originalText = sourceCode.text;
|
---|
| 162 | const start = fixes[0].range[0];
|
---|
| 163 | const end = fixes[fixes.length - 1].range[1];
|
---|
| 164 | let text = "";
|
---|
| 165 | let lastPos = Number.MIN_SAFE_INTEGER;
|
---|
| 166 |
|
---|
| 167 | for (const fix of fixes) {
|
---|
| 168 | assert(fix.range[0] >= lastPos, "Fix objects must not be overlapped in a report.");
|
---|
| 169 |
|
---|
| 170 | if (fix.range[0] >= 0) {
|
---|
| 171 | text += originalText.slice(Math.max(0, start, lastPos), fix.range[0]);
|
---|
| 172 | }
|
---|
| 173 | text += fix.text;
|
---|
| 174 | lastPos = fix.range[1];
|
---|
| 175 | }
|
---|
| 176 | text += originalText.slice(Math.max(0, start, lastPos), end);
|
---|
| 177 |
|
---|
| 178 | return { range: [start, end], text };
|
---|
| 179 | }
|
---|
| 180 |
|
---|
| 181 | /**
|
---|
| 182 | * Gets one fix object from the given descriptor.
|
---|
| 183 | * If the descriptor retrieves multiple fixes, this merges those to one.
|
---|
| 184 | * @param {MessageDescriptor} descriptor The report descriptor.
|
---|
| 185 | * @param {SourceCode} sourceCode The source code object to get text between fixes.
|
---|
| 186 | * @returns {({text: string, range: number[]}|null)} The fix for the descriptor
|
---|
| 187 | */
|
---|
| 188 | function normalizeFixes(descriptor, sourceCode) {
|
---|
| 189 | if (typeof descriptor.fix !== "function") {
|
---|
| 190 | return null;
|
---|
| 191 | }
|
---|
| 192 |
|
---|
| 193 | // @type {null | Fix | Fix[] | IterableIterator<Fix>}
|
---|
| 194 | const fix = descriptor.fix(ruleFixer);
|
---|
| 195 |
|
---|
| 196 | // Merge to one.
|
---|
| 197 | if (fix && Symbol.iterator in fix) {
|
---|
| 198 | return mergeFixes(Array.from(fix), sourceCode);
|
---|
| 199 | }
|
---|
| 200 |
|
---|
| 201 | assertValidFix(fix);
|
---|
| 202 | return cloneFix(fix);
|
---|
| 203 | }
|
---|
| 204 |
|
---|
| 205 | /**
|
---|
| 206 | * Gets an array of suggestion objects from the given descriptor.
|
---|
| 207 | * @param {MessageDescriptor} descriptor The report descriptor.
|
---|
| 208 | * @param {SourceCode} sourceCode The source code object to get text between fixes.
|
---|
| 209 | * @param {Object} messages Object of meta messages for the rule.
|
---|
| 210 | * @returns {Array<SuggestionResult>} The suggestions for the descriptor
|
---|
| 211 | */
|
---|
| 212 | function mapSuggestions(descriptor, sourceCode, messages) {
|
---|
| 213 | if (!descriptor.suggest || !Array.isArray(descriptor.suggest)) {
|
---|
| 214 | return [];
|
---|
| 215 | }
|
---|
| 216 |
|
---|
| 217 | return descriptor.suggest
|
---|
| 218 | .map(suggestInfo => {
|
---|
| 219 | const computedDesc = suggestInfo.desc || messages[suggestInfo.messageId];
|
---|
| 220 |
|
---|
| 221 | return {
|
---|
| 222 | ...suggestInfo,
|
---|
| 223 | desc: interpolate(computedDesc, suggestInfo.data),
|
---|
| 224 | fix: normalizeFixes(suggestInfo, sourceCode)
|
---|
| 225 | };
|
---|
| 226 | })
|
---|
| 227 |
|
---|
| 228 | // Remove suggestions that didn't provide a fix
|
---|
| 229 | .filter(({ fix }) => fix);
|
---|
| 230 | }
|
---|
| 231 |
|
---|
| 232 | /**
|
---|
| 233 | * Creates information about the report from a descriptor
|
---|
| 234 | * @param {Object} options Information about the problem
|
---|
| 235 | * @param {string} options.ruleId Rule ID
|
---|
| 236 | * @param {(0|1|2)} options.severity Rule severity
|
---|
| 237 | * @param {(ASTNode|null)} options.node Node
|
---|
| 238 | * @param {string} options.message Error message
|
---|
| 239 | * @param {string} [options.messageId] The error message ID.
|
---|
| 240 | * @param {{start: SourceLocation, end: (SourceLocation|null)}} options.loc Start and end location
|
---|
| 241 | * @param {{text: string, range: (number[]|null)}} options.fix The fix object
|
---|
| 242 | * @param {Array<{text: string, range: (number[]|null)}>} options.suggestions The array of suggestions objects
|
---|
| 243 | * @returns {LintMessage} Information about the report
|
---|
| 244 | */
|
---|
| 245 | function createProblem(options) {
|
---|
| 246 | const problem = {
|
---|
| 247 | ruleId: options.ruleId,
|
---|
| 248 | severity: options.severity,
|
---|
| 249 | message: options.message,
|
---|
| 250 | line: options.loc.start.line,
|
---|
| 251 | column: options.loc.start.column + 1,
|
---|
| 252 | nodeType: options.node && options.node.type || null
|
---|
| 253 | };
|
---|
| 254 |
|
---|
| 255 | /*
|
---|
| 256 | * If this isn’t in the conditional, some of the tests fail
|
---|
| 257 | * because `messageId` is present in the problem object
|
---|
| 258 | */
|
---|
| 259 | if (options.messageId) {
|
---|
| 260 | problem.messageId = options.messageId;
|
---|
| 261 | }
|
---|
| 262 |
|
---|
| 263 | if (options.loc.end) {
|
---|
| 264 | problem.endLine = options.loc.end.line;
|
---|
| 265 | problem.endColumn = options.loc.end.column + 1;
|
---|
| 266 | }
|
---|
| 267 |
|
---|
| 268 | if (options.fix) {
|
---|
| 269 | problem.fix = options.fix;
|
---|
| 270 | }
|
---|
| 271 |
|
---|
| 272 | if (options.suggestions && options.suggestions.length > 0) {
|
---|
| 273 | problem.suggestions = options.suggestions;
|
---|
| 274 | }
|
---|
| 275 |
|
---|
| 276 | return problem;
|
---|
| 277 | }
|
---|
| 278 |
|
---|
| 279 | /**
|
---|
| 280 | * Validates that suggestions are properly defined. Throws if an error is detected.
|
---|
| 281 | * @param {Array<{ desc?: string, messageId?: string }>} suggest The incoming suggest data.
|
---|
| 282 | * @param {Object} messages Object of meta messages for the rule.
|
---|
| 283 | * @returns {void}
|
---|
| 284 | */
|
---|
| 285 | function validateSuggestions(suggest, messages) {
|
---|
| 286 | if (suggest && Array.isArray(suggest)) {
|
---|
| 287 | suggest.forEach(suggestion => {
|
---|
| 288 | if (suggestion.messageId) {
|
---|
| 289 | const { messageId } = suggestion;
|
---|
| 290 |
|
---|
| 291 | if (!messages) {
|
---|
| 292 | throw new TypeError(`context.report() called with a suggest option with a messageId '${messageId}', but no messages were present in the rule metadata.`);
|
---|
| 293 | }
|
---|
| 294 |
|
---|
| 295 | if (!messages[messageId]) {
|
---|
| 296 | throw new TypeError(`context.report() called with a suggest option with a messageId '${messageId}' which is not present in the 'messages' config: ${JSON.stringify(messages, null, 2)}`);
|
---|
| 297 | }
|
---|
| 298 |
|
---|
| 299 | if (suggestion.desc) {
|
---|
| 300 | throw new TypeError("context.report() called with a suggest option that defines both a 'messageId' and an 'desc'. Please only pass one.");
|
---|
| 301 | }
|
---|
| 302 | } else if (!suggestion.desc) {
|
---|
| 303 | throw new TypeError("context.report() called with a suggest option that doesn't have either a `desc` or `messageId`");
|
---|
| 304 | }
|
---|
| 305 |
|
---|
| 306 | if (typeof suggestion.fix !== "function") {
|
---|
| 307 | throw new TypeError(`context.report() called with a suggest option without a fix function. See: ${suggestion}`);
|
---|
| 308 | }
|
---|
| 309 | });
|
---|
| 310 | }
|
---|
| 311 | }
|
---|
| 312 |
|
---|
| 313 | /**
|
---|
| 314 | * Returns a function that converts the arguments of a `context.report` call from a rule into a reported
|
---|
| 315 | * problem for the Node.js API.
|
---|
| 316 | * @param {{ruleId: string, severity: number, sourceCode: SourceCode, messageIds: Object, disableFixes: boolean}} metadata Metadata for the reported problem
|
---|
| 317 | * @param {SourceCode} sourceCode The `SourceCode` instance for the text being linted
|
---|
| 318 | * @returns {function(...args): LintMessage} Function that returns information about the report
|
---|
| 319 | */
|
---|
| 320 |
|
---|
| 321 | module.exports = function createReportTranslator(metadata) {
|
---|
| 322 |
|
---|
| 323 | /*
|
---|
| 324 | * `createReportTranslator` gets called once per enabled rule per file. It needs to be very performant.
|
---|
| 325 | * The report translator itself (i.e. the function that `createReportTranslator` returns) gets
|
---|
| 326 | * called every time a rule reports a problem, which happens much less frequently (usually, the vast
|
---|
| 327 | * majority of rules don't report any problems for a given file).
|
---|
| 328 | */
|
---|
| 329 | return (...args) => {
|
---|
| 330 | const descriptor = normalizeMultiArgReportCall(...args);
|
---|
| 331 | const messages = metadata.messageIds;
|
---|
| 332 |
|
---|
| 333 | assertValidNodeInfo(descriptor);
|
---|
| 334 |
|
---|
| 335 | let computedMessage;
|
---|
| 336 |
|
---|
| 337 | if (descriptor.messageId) {
|
---|
| 338 | if (!messages) {
|
---|
| 339 | throw new TypeError("context.report() called with a messageId, but no messages were present in the rule metadata.");
|
---|
| 340 | }
|
---|
| 341 | const id = descriptor.messageId;
|
---|
| 342 |
|
---|
| 343 | if (descriptor.message) {
|
---|
| 344 | throw new TypeError("context.report() called with a message and a messageId. Please only pass one.");
|
---|
| 345 | }
|
---|
| 346 | if (!messages || !Object.prototype.hasOwnProperty.call(messages, id)) {
|
---|
| 347 | throw new TypeError(`context.report() called with a messageId of '${id}' which is not present in the 'messages' config: ${JSON.stringify(messages, null, 2)}`);
|
---|
| 348 | }
|
---|
| 349 | computedMessage = messages[id];
|
---|
| 350 | } else if (descriptor.message) {
|
---|
| 351 | computedMessage = descriptor.message;
|
---|
| 352 | } else {
|
---|
| 353 | throw new TypeError("Missing `message` property in report() call; add a message that describes the linting problem.");
|
---|
| 354 | }
|
---|
| 355 |
|
---|
| 356 | validateSuggestions(descriptor.suggest, messages);
|
---|
| 357 |
|
---|
| 358 | return createProblem({
|
---|
| 359 | ruleId: metadata.ruleId,
|
---|
| 360 | severity: metadata.severity,
|
---|
| 361 | node: descriptor.node,
|
---|
| 362 | message: interpolate(computedMessage, descriptor.data),
|
---|
| 363 | messageId: descriptor.messageId,
|
---|
| 364 | loc: normalizeReportLoc(descriptor),
|
---|
| 365 | fix: metadata.disableFixes ? null : normalizeFixes(descriptor, metadata.sourceCode),
|
---|
| 366 | suggestions: metadata.disableFixes ? [] : mapSuggestions(descriptor, metadata.sourceCode, messages)
|
---|
| 367 | });
|
---|
| 368 | };
|
---|
| 369 | };
|
---|