Add infrastructure for rules to include fix information when logging violations, update MD047 (refs #80).

This commit is contained in:
David Anson 2019-08-16 19:56:52 -07:00
parent 6e086114b1
commit cdd87e647f
6 changed files with 248 additions and 19 deletions

View file

@ -416,15 +416,19 @@ Specifies which version of the `result` object to return (see the "Usage" sectio
below for examples).
Passing a `resultVersion` of `0` corresponds to the original, simple format where
each error is identified by rule name and line number. This is deprecated.
each error is identified by rule name and line number. *This is deprecated.*
Passing a `resultVersion` of `1` corresponds to a detailed format where each error
includes information about the line number, rule name, alias, description, as well
as any additional detail or context that is available. This is deprecated.
as any additional detail or context that is available. *This is deprecated.*
Passing a `resultVersion` of `2` corresponds to a detailed format where each error
includes information about the line number, rule names, description, as well as any
additional detail or context that is available. This is the default.
additional detail or context that is available. *This is the default.*
Passing a `resultVersion` of `3` corresponds to the detailed version `2` format
with additional information about fixes for certain errors. All errors for each
line are included (other versions collapse multiple errors for the same rule).
##### options.markdownItPlugins

View file

@ -52,6 +52,10 @@ A rule is implemented as an `Object` with four required properties:
- `details` is an optional `String` with information about what caused the error.
- `context` is an optional `String` with relevant text surrounding the error location.
- `range` is an optional `Array` with two `Number` values identifying the 1-based column and length of the error.
- `fixInfo` is an optional `Object` with information about how to fix the error:
- `editColumn` is an optional `Number` specifying the 1-based column number of the edit.
- `deleteCount` is an optional `Number` specifying the count of characters to delete with the edit.
- `insertText` is an optional `String` specifying text to insert as part of the edit.
The collection of helper functions shared by the built-in rules is available for use by custom rules in the [markdownlint-rule-helpers package](https://www.npmjs.com/package/markdownlint-rule-helpers).

View file

@ -4,7 +4,8 @@
// Regular expression for matching common newline characters
// See NEWLINES_RE in markdown-it/lib/rules_core/normalize.js
module.exports.newLineRe = /\r[\n\u0085]?|[\n\u2424\u2028\u0085]/;
const newLineRe = /\r[\n\u0085]?|[\n\u2424\u2028\u0085]/;
module.exports.newLineRe = newLineRe;
// Regular expression for matching common front matter (YAML and TOML)
module.exports.frontMatterRe =
@ -331,12 +332,13 @@ module.exports.forEachInlineCodeSpan =
};
// Adds a generic error object via the onError callback
function addError(onError, lineNumber, detail, context, range) {
function addError(onError, lineNumber, detail, context, range, fixInfo) {
onError({
"lineNumber": lineNumber,
"detail": detail,
"context": context,
"range": range
lineNumber,
detail,
context,
range,
fixInfo
});
}
module.exports.addError = addError;
@ -396,3 +398,22 @@ module.exports.frontMatterHasTitle =
return !ignoreFrontMatter &&
frontMatterLines.some((line) => frontMatterTitleRe.test(line));
};
// Applies as many fixes as possible to the input
module.exports.fixErrors = function fixErrors(input, errors) {
const lines = input.split(newLineRe);
errors.filter((error) => !!error.fixInfo).forEach((error) => {
const { lineNumber, fixInfo } = error;
const editColumn = fixInfo.editColumn || 1;
const deleteCount = fixInfo.deleteCount || 0;
const insertText = fixInfo.insertText || "";
const lineIndex = lineNumber - 1;
const editIndex = editColumn - 1;
const line = lines[lineIndex];
lines[lineIndex] =
line.slice(0, editIndex) +
insertText +
line.slice(editIndex + deleteCount);
});
return lines.join("\n");
};

View file

@ -381,7 +381,8 @@ function lintContent(
"lineNumber": errorInfo.lineNumber + frontMatterLines.length,
"detail": errorInfo.detail || null,
"context": errorInfo.context || null,
"range": errorInfo.range || null
"range": errorInfo.range || null,
"fixInfo": errorInfo.fixInfo || null
});
}
// Call (possibly external) rule function
@ -423,6 +424,9 @@ function lintContent(
errorObject.errorDetail = error.detail;
errorObject.errorContext = error.context;
errorObject.errorRange = error.range;
if (resultVersion === 3) {
errorObject.fixInfo = error.fixInfo;
}
return errorObject;
});
if (filteredErrors.length) {

View file

@ -12,7 +12,17 @@ module.exports = {
const lastLineNumber = params.lines.length;
const lastLine = params.lines[lastLineNumber - 1];
if (!isBlankLine(lastLine)) {
addError(onError, lastLineNumber);
addError(
onError,
lastLineNumber,
null,
null,
null,
{
"insertText": "\n",
"editColumn": lastLine.length + 1
}
);
}
}
};

View file

@ -34,10 +34,11 @@ function promisify(func, ...args) {
function createTestForFile(file) {
return function testForFile(test) {
test.expect(1);
test.expect(2);
const detailedResults = /[/\\]detailed-results-/.test(file);
const resultsFile = file.replace(/\.md$/, ".results.json");
const configFile = file.replace(/\.md$/, ".json");
let mergedConfig = null;
const actualPromise = promisify(fs.stat, configFile)
.then(
function configFileExists() {
@ -49,16 +50,29 @@ function createTestForFile(file) {
})
.then(
function lintWithConfig(config) {
const mergedConfig = {
mergedConfig = {
...defaultConfig,
...config
};
return promisify(markdownlint, {
"files": [ file ],
"config": mergedConfig,
"resultVersion": detailedResults ? 2 : 0
"resultVersion": detailedResults ? 2 : 3
});
});
})
.then(
function convertResultVersion2To0(resultVersion2) {
const result0 = {};
const result2or3 = resultVersion2[file];
result2or3.forEach(function forResult(result) {
const ruleName = result.ruleNames[0];
const lineNumbers = result0[ruleName] || [];
lineNumbers.push(result.lineNumber);
result0[ruleName] = lineNumbers;
});
return [ result0, result2or3 ];
}
);
const expectedPromise = detailedResults ?
promisify(fs.readFile, resultsFile, helpers.utf8Encoding)
.then(
@ -96,11 +110,35 @@ function createTestForFile(file) {
Promise.all([ actualPromise, expectedPromise ])
.then(
function compareResults(fulfillments) {
const actual = fulfillments[0];
const results = fulfillments[1];
const expected = {};
expected[file] = results;
const [ [ actual0, actual2or3 ], expected ] = fulfillments;
const actual = detailedResults ? actual2or3 : actual0;
test.deepEqual(actual, expected, "Line numbers are not correct.");
return actual2or3;
})
.then(
function verifyFixErrors(errors) {
if (detailedResults) {
return test.ok(true);
}
return promisify(fs.readFile, file, helpers.utf8Encoding)
.then(
function applyFixErrors(content) {
const corrections = helpers.fixErrors(content, errors);
return promisify(markdownlint, {
"strings": {
"input": corrections
},
"config": mergedConfig,
"resultVersion": 3
});
})
.then(
function checkFixErrors(newErrors) {
const unfixed = newErrors.input
.filter((error) => !!error.fixInfo);
test.deepEqual(unfixed, [], "Fixable error was not fixed.");
}
);
})
.catch()
.then(test.done);
@ -448,6 +486,43 @@ module.exports.resultFormattingV2 = function resultFormattingV2(test) {
});
};
module.exports.resultFormattingV3 = function resultFormattingV3(test) {
test.expect(3);
const options = {
"strings": {
"input": "# Heading"
},
"resultVersion": 3
};
markdownlint(options, function callback(err, actualResult) {
test.ifError(err);
const expectedResult = {
"input": [
{
"lineNumber": 1,
"ruleNames": [ "MD047", "single-trailing-newline" ],
"ruleDescription": "Files should end with a single newline character",
"ruleInformation": `${homepage}/blob/v${version}/doc/Rules.md#md047`,
"errorDetail": null,
"errorContext": null,
"errorRange": null,
"fixInfo": {
"insertText": "\n",
"editColumn": 10
}
}
]
};
test.deepEqual(actualResult, expectedResult, "Undetected issues.");
const actualMessage = actualResult.toString();
const expectedMessage =
"input: 1: MD047/single-trailing-newline" +
" Files should end with a single newline character";
test.equal(actualMessage, expectedMessage, "Incorrect message.");
test.done();
});
};
module.exports.stringInputLineEndings = function stringInputLineEndings(test) {
test.expect(2);
const options = {
@ -1638,6 +1713,117 @@ module.exports.forEachInlineCodeSpan = function forEachInlineCodeSpan(test) {
test.done();
};
module.exports.fixErrors = function fixErrors(test) {
test.expect(8);
const testCases = [
[
"Hello world.",
[],
"Hello world."
],
[
"Hello world.",
[
{
"lineNumber": 1,
"fixInfo": {}
}
],
"Hello world."
],
[
"Hello world.",
[
{
"lineNumber": 1,
"fixInfo": {
"insertText": "Very "
}
}
],
"Very Hello world."
],
[
"Hello world.",
[
{
"lineNumber": 1,
"fixInfo": {
"editColumn": 7,
"insertText": "big "
}
}
],
"Hello big world."
],
[
"Hello world.",
[
{
"lineNumber": 1,
"fixInfo": {
"deleteCount": 6
}
}
],
"world."
],
[
"Hello world.",
[
{
"lineNumber": 1,
"fixInfo": {
"editColumn": 7,
"deleteCount": 5,
"insertText": "there"
}
}
],
"Hello there."
],
[
"Hello world.",
[
{
"lineNumber": 1,
"fixInfo": {
"editColumn": 12,
"deleteCount": 1
}
},
{
"lineNumber": 1,
"fixInfo": {
"editColumn": 6,
"deleteCount": 1
}
}
],
"Helloworld"
],
[
"Hello world.",
[
{
"lineNumber": 1,
"fixInfo": {
"editColumn": 13,
"insertText": " Hi."
}
}
],
"Hello world. Hi."
]
];
testCases.forEach((testCase) => {
const [ input, errors, expected ] = testCase;
const actual = helpers.fixErrors(input, errors);
test.equal(actual, expected, "Incorrect fix applied.");
});
test.done();
};
module.exports.configSingle = function configSingle(test) {
test.expect(2);
markdownlint.readConfig("./test/config/config-child.json",