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 | ;
|
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 | /**
|
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 | */
|
59 | function 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 | */
|
94 | function 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 | */
|
108 | function 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 | * Check that a fix has a valid range.
|
120 | * @param {Fix|null} fix The fix to validate.
|
121 | * @returns {void}
|
122 | */
|
123 | function assertValidFix(fix) {
|
124 | if (fix) {
|
125 | assert(fix.range && typeof fix.range[0] === "number" && typeof fix.range[1] === "number", `Fix has invalid range: ${JSON.stringify(fix, null, 2)}`);
|
126 | }
|
127 | }
|
128 |
|
129 | /**
|
130 | * Compares items in a fixes array by range.
|
131 | * @param {Fix} a The first message.
|
132 | * @param {Fix} b The second message.
|
133 | * @returns {int} -1 if a comes before b, 1 if a comes after b, 0 if equal.
|
134 | * @private
|
135 | */
|
136 | function compareFixesByRange(a, b) {
|
137 | return a.range[0] - b.range[0] || a.range[1] - b.range[1];
|
138 | }
|
139 |
|
140 | /**
|
141 | * Merges the given fixes array into one.
|
142 | * @param {Fix[]} fixes The fixes to merge.
|
143 | * @param {SourceCode} sourceCode The source code object to get the text between fixes.
|
144 | * @returns {{text: string, range: number[]}} The merged fixes
|
145 | */
|
146 | function mergeFixes(fixes, sourceCode) {
|
147 | for (const fix of fixes) {
|
148 | assertValidFix(fix);
|
149 | }
|
150 |
|
151 | if (fixes.length === 0) {
|
152 | return null;
|
153 | }
|
154 | if (fixes.length === 1) {
|
155 | return fixes[0];
|
156 | }
|
157 |
|
158 | fixes.sort(compareFixesByRange);
|
159 |
|
160 | const originalText = sourceCode.text;
|
161 | const start = fixes[0].range[0];
|
162 | const end = fixes[fixes.length - 1].range[1];
|
163 | let text = "";
|
164 | let lastPos = Number.MIN_SAFE_INTEGER;
|
165 |
|
166 | for (const fix of fixes) {
|
167 | assert(fix.range[0] >= lastPos, "Fix objects must not be overlapped in a report.");
|
168 |
|
169 | if (fix.range[0] >= 0) {
|
170 | text += originalText.slice(Math.max(0, start, lastPos), fix.range[0]);
|
171 | }
|
172 | text += fix.text;
|
173 | lastPos = fix.range[1];
|
174 | }
|
175 | text += originalText.slice(Math.max(0, start, lastPos), end);
|
176 |
|
177 | return { range: [start, end], text };
|
178 | }
|
179 |
|
180 | /**
|
181 | * Gets one fix object from the given descriptor.
|
182 | * If the descriptor retrieves multiple fixes, this merges those to one.
|
183 | * @param {MessageDescriptor} descriptor The report descriptor.
|
184 | * @param {SourceCode} sourceCode The source code object to get text between fixes.
|
185 | * @returns {({text: string, range: number[]}|null)} The fix for the descriptor
|
186 | */
|
187 | function normalizeFixes(descriptor, sourceCode) {
|
188 | if (typeof descriptor.fix !== "function") {
|
189 | return null;
|
190 | }
|
191 |
|
192 | // @type {null | Fix | Fix[] | IterableIterator<Fix>}
|
193 | const fix = descriptor.fix(ruleFixer);
|
194 |
|
195 | // Merge to one.
|
196 | if (fix && Symbol.iterator in fix) {
|
197 | return mergeFixes(Array.from(fix), sourceCode);
|
198 | }
|
199 |
|
200 | assertValidFix(fix);
|
201 | return fix;
|
202 | }
|
203 |
|
204 | /**
|
205 | * Gets an array of suggestion objects from the given descriptor.
|
206 | * @param {MessageDescriptor} descriptor The report descriptor.
|
207 | * @param {SourceCode} sourceCode The source code object to get text between fixes.
|
208 | * @param {Object} messages Object of meta messages for the rule.
|
209 | * @returns {Array<SuggestionResult>} The suggestions for the descriptor
|
210 | */
|
211 | function mapSuggestions(descriptor, sourceCode, messages) {
|
212 | if (!descriptor.suggest || !Array.isArray(descriptor.suggest)) {
|
213 | return [];
|
214 | }
|
215 |
|
216 | return descriptor.suggest
|
217 | .map(suggestInfo => {
|
218 | const computedDesc = suggestInfo.desc || messages[suggestInfo.messageId];
|
219 |
|
220 | return {
|
221 | ...suggestInfo,
|
222 | desc: interpolate(computedDesc, suggestInfo.data),
|
223 | fix: normalizeFixes(suggestInfo, sourceCode)
|
224 | };
|
225 | })
|
226 |
|
227 | // Remove suggestions that didn't provide a fix
|
228 | .filter(({ fix }) => fix);
|
229 | }
|
230 |
|
231 | /**
|
232 | * Creates information about the report from a descriptor
|
233 | * @param {Object} options Information about the problem
|
234 | * @param {string} options.ruleId Rule ID
|
235 | * @param {(0|1|2)} options.severity Rule severity
|
236 | * @param {(ASTNode|null)} options.node Node
|
237 | * @param {string} options.message Error message
|
238 | * @param {string} [options.messageId] The error message ID.
|
239 | * @param {{start: SourceLocation, end: (SourceLocation|null)}} options.loc Start and end location
|
240 | * @param {{text: string, range: (number[]|null)}} options.fix The fix object
|
241 | * @param {Array<{text: string, range: (number[]|null)}>} options.suggestions The array of suggestions objects
|
242 | * @returns {function(...args): ReportInfo} Function that returns information about the report
|
243 | */
|
244 | function createProblem(options) {
|
245 | const problem = {
|
246 | ruleId: options.ruleId,
|
247 | severity: options.severity,
|
248 | message: options.message,
|
249 | line: options.loc.start.line,
|
250 | column: options.loc.start.column + 1,
|
251 | nodeType: options.node && options.node.type || null
|
252 | };
|
253 |
|
254 | /*
|
255 | * If this isn’t in the conditional, some of the tests fail
|
256 | * because `messageId` is present in the problem object
|
257 | */
|
258 | if (options.messageId) {
|
259 | problem.messageId = options.messageId;
|
260 | }
|
261 |
|
262 | if (options.loc.end) {
|
263 | problem.endLine = options.loc.end.line;
|
264 | problem.endColumn = options.loc.end.column + 1;
|
265 | }
|
266 |
|
267 | if (options.fix) {
|
268 | problem.fix = options.fix;
|
269 | }
|
270 |
|
271 | if (options.suggestions && options.suggestions.length > 0) {
|
272 | problem.suggestions = options.suggestions;
|
273 | }
|
274 |
|
275 | return problem;
|
276 | }
|
277 |
|
278 | /**
|
279 | * Validates that suggestions are properly defined. Throws if an error is detected.
|
280 | * @param {Array<{ desc?: string, messageId?: string }>} suggest The incoming suggest data.
|
281 | * @param {Object} messages Object of meta messages for the rule.
|
282 | * @returns {void}
|
283 | */
|
284 | function validateSuggestions(suggest, messages) {
|
285 | if (suggest && Array.isArray(suggest)) {
|
286 | suggest.forEach(suggestion => {
|
287 | if (suggestion.messageId) {
|
288 | const { messageId } = suggestion;
|
289 |
|
290 | if (!messages) {
|
291 | throw new TypeError(`context.report() called with a suggest option with a messageId '${messageId}', but no messages were present in the rule metadata.`);
|
292 | }
|
293 |
|
294 | if (!messages[messageId]) {
|
295 | 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)}`);
|
296 | }
|
297 |
|
298 | if (suggestion.desc) {
|
299 | throw new TypeError("context.report() called with a suggest option that defines both a 'messageId' and an 'desc'. Please only pass one.");
|
300 | }
|
301 | } else if (!suggestion.desc) {
|
302 | throw new TypeError("context.report() called with a suggest option that doesn't have either a `desc` or `messageId`");
|
303 | }
|
304 |
|
305 | if (typeof suggestion.fix !== "function") {
|
306 | throw new TypeError(`context.report() called with a suggest option without a fix function. See: ${suggestion}`);
|
307 | }
|
308 | });
|
309 | }
|
310 | }
|
311 |
|
312 | /**
|
313 | * Returns a function that converts the arguments of a `context.report` call from a rule into a reported
|
314 | * problem for the Node.js API.
|
315 | * @param {{ruleId: string, severity: number, sourceCode: SourceCode, messageIds: Object, disableFixes: boolean}} metadata Metadata for the reported problem
|
316 | * @param {SourceCode} sourceCode The `SourceCode` instance for the text being linted
|
317 | * @returns {function(...args): ReportInfo} Function that returns information about the report
|
318 | */
|
319 |
|
320 | module.exports = function createReportTranslator(metadata) {
|
321 |
|
322 | /*
|
323 | * `createReportTranslator` gets called once per enabled rule per file. It needs to be very performant.
|
324 | * The report translator itself (i.e. the function that `createReportTranslator` returns) gets
|
325 | * called every time a rule reports a problem, which happens much less frequently (usually, the vast
|
326 | * majority of rules don't report any problems for a given file).
|
327 | */
|
328 | return (...args) => {
|
329 | const descriptor = normalizeMultiArgReportCall(...args);
|
330 | const messages = metadata.messageIds;
|
331 |
|
332 | assertValidNodeInfo(descriptor);
|
333 |
|
334 | let computedMessage;
|
335 |
|
336 | if (descriptor.messageId) {
|
337 | if (!messages) {
|
338 | throw new TypeError("context.report() called with a messageId, but no messages were present in the rule metadata.");
|
339 | }
|
340 | const id = descriptor.messageId;
|
341 |
|
342 | if (descriptor.message) {
|
343 | throw new TypeError("context.report() called with a message and a messageId. Please only pass one.");
|
344 | }
|
345 | if (!messages || !Object.prototype.hasOwnProperty.call(messages, id)) {
|
346 | throw new TypeError(`context.report() called with a messageId of '${id}' which is not present in the 'messages' config: ${JSON.stringify(messages, null, 2)}`);
|
347 | }
|
348 | computedMessage = messages[id];
|
349 | } else if (descriptor.message) {
|
350 | computedMessage = descriptor.message;
|
351 | } else {
|
352 | throw new TypeError("Missing `message` property in report() call; add a message that describes the linting problem.");
|
353 | }
|
354 |
|
355 | validateSuggestions(descriptor.suggest, messages);
|
356 |
|
357 | return createProblem({
|
358 | ruleId: metadata.ruleId,
|
359 | severity: metadata.severity,
|
360 | node: descriptor.node,
|
361 | message: interpolate(computedMessage, descriptor.data),
|
362 | messageId: descriptor.messageId,
|
363 | loc: normalizeReportLoc(descriptor),
|
364 | fix: metadata.disableFixes ? null : normalizeFixes(descriptor, metadata.sourceCode),
|
365 | suggestions: metadata.disableFixes ? [] : mapSuggestions(descriptor, metadata.sourceCode, messages)
|
366 | });
|
367 | };
|
368 | };
|