summaryrefslogtreecommitdiff
path: root/tools/node_modules/eslint/node_modules/eslint-plugin-markdown/lib/processor.js
blob: a3c9e06da2852e8564a3d548a42c72b99a098aec (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
/**
 * @fileoverview Processes Markdown files for consumption by ESLint.
 * @author Brandon Mills
 */

"use strict";

var assign = require("object-assign");
var unified = require("unified");
var remarkParse = require("remark-parse");

var SUPPORTED_SYNTAXES = ["js", "javascript", "node", "jsx"];
var UNSATISFIABLE_RULES = [
    "eol-last", // The Markdown parser strips trailing newlines in code fences
    "unicode-bom" // Code blocks will begin in the middle of Markdown files
];
var SUPPORTS_AUTOFIX = true;

var markdown = unified().use(remarkParse);

var blocks = [];

/**
 * Performs a depth-first traversal of the Markdown AST.
 * @param {ASTNode} node A Markdown AST node.
 * @param {object} callbacks A map of node types to callbacks.
 * @param {object} [parent] The node's parent AST node.
 * @returns {void}
 */
function traverse(node, callbacks, parent) {
    var i;

    if (callbacks[node.type]) {
        callbacks[node.type](node, parent);
    }

    if (typeof node.children !== "undefined") {
        for (i = 0; i < node.children.length; i++) {
            traverse(node.children[i], callbacks, node);
        }
    }
}

/**
 * Converts leading HTML comments to JS block comments.
 * @param {string} html The text content of an HTML AST node.
 * @returns {string[]} An array of JS block comments.
 */
function getComment(html) {
    var commentStart = "<!--";
    var commentEnd = "-->";
    var regex = /^(eslint\b|global\s)/;

    if (
        html.slice(0, commentStart.length) !== commentStart ||
        html.slice(-commentEnd.length) !== commentEnd
    ) {
        return "";
    }

    html = html.slice(commentStart.length, -commentEnd.length);

    if (!regex.test(html.trim())) {
        return "";
    }

    return html;
}

var leadingWhitespaceRegex = /^\s*/;

/**
 * When applying fixes, the postprocess step needs to know how to map fix ranges
 * from their location in the linted JS to the original offset in the Markdown.
 * Configuration comments and indentation trimming both complicate this process.
 *
 * Configuration comments appear in the linted JS but not in the Markdown code
 * block. Fixes to configuration comments would cause undefined behavior and
 * should be ignored during postprocessing. Fixes to actual code after
 * configuration comments need to be mapped back to the code block after
 * removing any offset due to configuration comments.
 *
 * Fenced code blocks can be indented by up to three spaces at the opening
 * fence. Inside of a list, for example, this indent can be in addition to the
 * indent already required for list item children. Leading whitespace inside
 * indented code blocks is trimmed up to the level of the opening fence and does
 * not appear in the linted code. Further, lines can have less leading
 * whitespace than the opening fence, so not all lines are guaranteed to have
 * the same column offset as the opening fence.
 *
 * The source code of a non-configuration-comment line in the linted JS is a
 * suffix of the corresponding line in the Markdown code block. There are no
 * differences within the line, so the mapping need only provide the offset
 * delta at the beginning of each line.
 *
 * @param {string} text The text of the file.
 * @param {ASTNode} node A Markdown code block AST node.
 * @param {comments} comments List of configuration comment strings that will be
 *     inserted at the beginning of the code block.
 * @returns {object[]} A list of offset-based adjustments, where lookups are
 *     done based on the `js` key, which represents the range in the linted JS,
 *     and the `md` key is the offset delta that, when added to the JS range,
 *     returns the corresponding location in the original Markdown source.
 */
function getBlockRangeMap(text, node, comments) {
    var baseIndent,
        code,
        commentLength,
        i,
        jsOffset,
        leadingWhitespaceLength,
        line,
        lines,
        mdOffset,
        rangeMap,
        startOffset,
        trimLength;

    /*
     * The parser sets the fenced code block's start offset to wherever content
     * should normally begin (typically the first column of the line, but more
     * inside a list item, for example). The code block's opening fance may be
     * further indented by up to three characters. If the code block has
     * additional indenting, the opening fence's first backtick may be up to
     * three whitespace characters after the start offset.
     */
    startOffset = node.position.start.offset;

    /*
     * Extract the Markdown source to determine the leading whitespace for each
     * line.
     */
    code = text.slice(startOffset, node.position.end.offset);
    lines = code.split("\n");

    /*
     * The parser trims leading whitespace from each line of code within the
     * fenced code block up to the opening fence's first backtick. The first
     * backtick's column is the AST node's starting column plus any additional
     * indentation.
     */
    baseIndent = node.position.start.column - 1
        + leadingWhitespaceRegex.exec(lines[0])[0].length;

    /*
     * Track the length of any inserted configuration comments at the beginning
     * of the linted JS and start the JS offset lookup keys at this index.
     */
    commentLength = comments.reduce(function(len, comment) {
        return len + comment.length + 1;
    }, 0);

    /*
     * In case there are configuration comments, initialize the map so that the
     * first lookup index is always 0. If there are no configuration comments,
     * the lookup index will also be 0, and the lookup should always go to the
     * last range that matches, skipping this initialization entry.
     */
    rangeMap = [{
        js: 0,
        md: 0
    }];

    // Start the JS offset after any configuration comments.
    jsOffset = commentLength;

    /*
     * Start the Markdown offset at the beginning of the block's first line of
     * actual code. The first line of the block is always the opening fence, so
     * the code begins on the second line.
     */
    mdOffset = startOffset + lines[0].length + 1;

    /*
     * For each line, determine how much leading whitespace was trimmed due to
     * indentation. Increase the JS lookup offset by the length of the line
     * post-trimming and the Markdown offset by the total line length.
     */
    for (i = 0; i + 1 < lines.length; i++) {
        line = lines[i + 1];
        leadingWhitespaceLength = leadingWhitespaceRegex.exec(line)[0].length;
        // The parser trims leading whitespace up to the level of the opening
        // fence, so keep any additional indentation beyond that.
        trimLength = Math.min(baseIndent, leadingWhitespaceLength);

        rangeMap.push({
            js: jsOffset,
            // Advance `trimLength` character from the beginning of the Markdown
            // line to the beginning of the equivalent JS line, then compute the
            // delta.
            md: mdOffset + trimLength - jsOffset
        });

        // Accumulate the current line in the offsets, and don't forget the
        // newline.
        mdOffset += line.length + 1;
        jsOffset += line.length - trimLength + 1;
    }
    return rangeMap;
}

/**
 * Extracts lintable JavaScript code blocks from Markdown text.
 * @param {string} text The text of the file.
 * @returns {string[]} Source code strings to lint.
 */
function preprocess(text) {
    var ast = markdown.parse(text);

    blocks = [];
    traverse(ast, {
        "code": function(node, parent) {
            var comments = [];
            var index, previousNode, comment;

            if (node.lang && SUPPORTED_SYNTAXES.indexOf(node.lang.split(" ")[0].toLowerCase()) >= 0) {
                index = parent.children.indexOf(node) - 1;
                previousNode = parent.children[index];
                while (previousNode && previousNode.type === "html") {
                    comment = getComment(previousNode.value);

                    if (!comment) {
                        break;
                    }

                    if (comment.trim() === "eslint-skip") {
                        return;
                    }

                    comments.unshift("/*" + comment + "*/");
                    index--;
                    previousNode = parent.children[index];
                }

                blocks.push(assign({}, node, {
                    comments: comments,
                    rangeMap: getBlockRangeMap(text, node, comments)
                }));
            }
        }
    });

    return blocks.map(function(block) {
        return block.comments.concat(block.value).concat("").join("\n");
    });
}

/**
 * Creates a map function that adjusts messages in a code block.
 * @param {Block} block A code block.
 * @returns {function} A function that adjusts messages in a code block.
 */
function adjustBlock(block) {
    var leadingCommentLines = block.comments.reduce(function(count, comment) {
        return count + comment.split("\n").length;
    }, 0);

    var blockStart = block.position.start.line;

    /**
     * Adjusts ESLint messages to point to the correct location in the Markdown.
     * @param {Message} message A message from ESLint.
     * @returns {Message} The same message, but adjusted ot the correct location.
     */
    return function adjustMessage(message) {

        var lineInCode = message.line - leadingCommentLines;
        var endLine = message.endLine - leadingCommentLines;
        if (lineInCode < 1) {
            return null;
        }

        var out = {
            line: lineInCode + blockStart,
            endLine: endLine ? endLine + blockStart : endLine,
            column: message.column + block.position.indent[lineInCode - 1] - 1
        };

        var adjustedFix = {};
        if (message.fix) {
            adjustedFix.fix = {
                range: message.fix.range.map(function(range) {
                    // Advance through the block's range map to find the last
                    // matching range by finding the first range too far and
                    // then going back one.
                    var i = 1;
                    while (i < block.rangeMap.length && block.rangeMap[i].js < range) {
                        i++;
                    }

                    // Apply the mapping delta for this range.
                    return range + block.rangeMap[i - 1].md;
                }),
                text: message.fix.text
            };
        }

        return assign({}, message, out, adjustedFix);
    };
}

/**
 * Excludes unsatisfiable rules from the list of messages.
 * @param {Message} message A message from the linter.
 * @returns {boolean} True if the message should be included in output.
 */
function excludeUnsatisfiableRules(message) {
    return message && UNSATISFIABLE_RULES.indexOf(message.ruleId) < 0;
}

/**
 * Transforms generated messages for output.
 * @param {Array<Message[]>} messages An array containing one array of messages
 *     for each code block returned from `preprocess`.
 * @returns {Message[]} A flattened array of messages with mapped locations.
 */
function postprocess(messages) {
    return [].concat.apply([], messages.map(function(group, i) {
        var adjust = adjustBlock(blocks[i]);
        return group.map(adjust).filter(excludeUnsatisfiableRules);
    }));
}

module.exports = {
    preprocess: preprocess,
    postprocess: postprocess,
    supportsAutofix: SUPPORTS_AUTOFIX
};