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