diff --git a/doc/CustomRules.md b/doc/CustomRules.md index 44671ed4..964554f8 100644 --- a/doc/CustomRules.md +++ b/doc/CustomRules.md @@ -53,6 +53,7 @@ A rule is implemented as an `Object` with four required properties: - `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: + - `lineNumber` is an optional `Number` specifying the 1-based line number of the edit. - `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. diff --git a/helpers/helpers.js b/helpers/helpers.js index 58f5313a..f407e7ef 100644 --- a/helpers/helpers.js +++ b/helpers/helpers.js @@ -359,19 +359,19 @@ module.exports.addErrorDetailIf = function addErrorDetailIf( }; // Adds an error object with context via the onError callback -module.exports.addErrorContext = - function addErrorContext(onError, lineNumber, context, left, right, range) { - if (context.length <= 30) { - // Nothing to do - } else if (left && right) { - context = context.substr(0, 15) + "..." + context.substr(-15); - } else if (right) { - context = "..." + context.substr(-30); - } else { - context = context.substr(0, 30) + "..."; - } - addError(onError, lineNumber, null, context, range); - }; +module.exports.addErrorContext = function addErrorContext( + onError, lineNumber, context, left, right, range, fixInfo) { + if (context.length <= 30) { + // Nothing to do + } else if (left && right) { + context = context.substr(0, 15) + "..." + context.substr(-15); + } else if (right) { + context = "..." + context.substr(-30); + } else { + context = context.substr(0, 30) + "..."; + } + addError(onError, lineNumber, null, context, range, fixInfo); +}; // Returns a range object for a line by applying a RegExp module.exports.rangeFromRegExp = function rangeFromRegExp(line, regexp) { @@ -403,20 +403,46 @@ module.exports.frontMatterHasTitle = // 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 || ""; + // Normalize fixInfo objects + const fixInfos = errors.filter((error) => !!error.fixInfo).map((error) => { + const { fixInfo } = error; + return { + "lineNumber": fixInfo.lineNumber || error.lineNumber, + "editColumn": fixInfo.editColumn || 1, + "deleteCount": fixInfo.deleteCount || 0, + "insertText": fixInfo.insertText || "" + }; + }); + // Sort bottom-to-top, deletes last, right-to-left, long-to-short + fixInfos.sort((a, b) => ( + (b.lineNumber - a.lineNumber) || + ((a.deleteCount === -1) ? 1 : ((b.deleteCount === -1) ? -1 : 0)) || + (b.editColumn - a.editColumn) || + (b.insertText.length - a.insertText.length) + )); + // Apply all fixes + let lastLineIndex = -1; + let lastEditIndex = -1; + fixInfos.forEach((fixInfo) => { + const { lineNumber, editColumn, deleteCount, insertText } = fixInfo; const lineIndex = lineNumber - 1; const editIndex = editColumn - 1; - const line = lines[lineIndex]; - lines[lineIndex] = - (deleteCount === -1) ? - null : - line.slice(0, editIndex) + - insertText + - line.slice(editIndex + deleteCount); + if ( + (lineIndex !== lastLineIndex) || + ((editIndex + deleteCount) < lastEditIndex) || + (deleteCount === -1) + ) { + const line = lines[lineIndex]; + lines[lineIndex] = + (deleteCount === -1) ? + null : + line.slice(0, editIndex) + + insertText + + line.slice(editIndex + deleteCount); + } + lastLineIndex = lineIndex; + lastEditIndex = editIndex; }); + // Return corrected input return lines.filter((line) => line !== null).join("\n"); }; diff --git a/lib/md022.js b/lib/md022.js index 9cb9ee30..65e8fc52 100644 --- a/lib/md022.js +++ b/lib/md022.js @@ -22,16 +22,33 @@ module.exports = { const [ topIndex, nextIndex ] = token.map; for (let i = 0; i < linesAbove; i++) { if (!isBlankLine(lines[topIndex - i - 1])) { - addErrorDetailIf(onError, topIndex + 1, linesAbove, i, "Above", - lines[topIndex].trim()); - return; + addErrorDetailIf( + onError, + topIndex + 1, + linesAbove, + i, + "Above", + lines[topIndex].trim(), + null, + { + "insertText": "\n" + }); } } for (let i = 0; i < linesBelow; i++) { if (!isBlankLine(lines[nextIndex + i])) { - addErrorDetailIf(onError, topIndex + 1, linesBelow, i, "Below", - lines[topIndex].trim()); - return; + addErrorDetailIf( + onError, + topIndex + 1, + linesBelow, + i, + "Below", + lines[topIndex].trim(), + null, + { + "lineNumber": nextIndex + 1, + "insertText": "\n" + }); } } }); diff --git a/lib/md031.js b/lib/md031.js index 99201456..34c052cd 100644 --- a/lib/md031.js +++ b/lib/md031.js @@ -14,10 +14,22 @@ module.exports = { const includeListItems = (listItems === undefined) ? true : !!listItems; const { lines } = params; forEachLine(lineMetadata(), (line, i, inCode, onFence, inTable, inItem) => { - if ((((onFence > 0) && !isBlankLine(lines[i - 1])) || - ((onFence < 0) && !isBlankLine(lines[i + 1]))) && - (includeListItems || !inItem)) { - addErrorContext(onError, i + 1, lines[i].trim()); + const onTopFence = (onFence > 0); + const onBottomFence = (onFence < 0); + if ((includeListItems || !inItem) && + ((onTopFence && !isBlankLine(lines[i - 1])) || + (onBottomFence && !isBlankLine(lines[i + 1])))) { + addErrorContext( + onError, + i + 1, + lines[i].trim(), + null, + null, + null, + { + "lineNumber": i + (onTopFence ? 1 : 2), + "insertText": "\n" + }); } }); } diff --git a/lib/md032.js b/lib/md032.js index a0f54786..607f167a 100644 --- a/lib/md032.js +++ b/lib/md032.js @@ -5,6 +5,8 @@ const { addErrorContext, isBlankLine } = require("../helpers"); const { flattenedLists } = require("./cache"); +const quotePrefixRe = /^[>\s]*/; + module.exports = { "names": [ "MD032", "blanks-around-lists" ], "description": "Lists should be surrounded by blank lines", @@ -14,11 +16,34 @@ module.exports = { flattenedLists().filter((list) => !list.nesting).forEach((list) => { const firstIndex = list.open.map[0]; if (!isBlankLine(lines[firstIndex - 1])) { - addErrorContext(onError, firstIndex + 1, lines[firstIndex].trim()); + const line = lines[firstIndex]; + const quotePrefix = line.match(quotePrefixRe)[0].trimEnd(); + addErrorContext( + onError, + firstIndex + 1, + line.trim(), + null, + null, + null, + { + "insertText": `${quotePrefix}\n` + }); } const lastIndex = list.lastLineIndex - 1; if (!isBlankLine(lines[lastIndex + 1])) { - addErrorContext(onError, lastIndex + 1, lines[lastIndex].trim()); + const line = lines[lastIndex]; + const quotePrefix = line.match(quotePrefixRe)[0].trimEnd(); + addErrorContext( + onError, + lastIndex + 1, + line.trim(), + null, + null, + null, + { + "lineNumber": lastIndex + 2, + "insertText": `${quotePrefix}\n` + }); } }); } diff --git a/test/markdownlint-test.js b/test/markdownlint-test.js index 9aa77b36..537d5c7a 100644 --- a/test/markdownlint-test.js +++ b/test/markdownlint-test.js @@ -1898,7 +1898,7 @@ module.exports.forEachInlineCodeSpan = function forEachInlineCodeSpan(test) { }; module.exports.fixErrors = function fixErrors(test) { - test.expect(10); + test.expect(23); const testCases = [ [ "Hello world.", @@ -2022,6 +2022,244 @@ module.exports.fixErrors = function fixErrors(test) { } ], "Hello" + ], + [ + "Hello\nworld", + [ + { + "lineNumber": 2, + "fixInfo": { + "lineNumber": 1, + "deleteCount": -1 + } + } + ], + "world" + ], + [ + "Hello\nworld", + [ + { + "lineNumber": 1, + "fixInfo": { + "lineNumber": 2, + "deleteCount": -1 + } + } + ], + "Hello" + ], + [ + "Hello world", + [ + { + "lineNumber": 1, + "fixInfo": { + "editColumn": 4, + "deleteCount": 1 + } + }, + { + "lineNumber": 1, + "fixInfo": { + "editColumn": 10, + "deleteCount": 1 + } + } + ], + "Helo word" + ], + [ + "Hello world", + [ + { + "lineNumber": 1, + "fixInfo": { + "editColumn": 10, + "deleteCount": 1 + } + }, + { + "lineNumber": 1, + "fixInfo": { + "editColumn": 4, + "deleteCount": 1 + } + } + ], + "Helo word" + ], + [ + "Hello\nworld", + [ + { + "fixInfo": { + "lineNumber": 1, + "deleteCount": -1 + } + }, + { + "fixInfo": { + "lineNumber": 1, + "insertText": "Big " + } + } + ], + "world" + ], + [ + "Hello\nworld", + [ + { + "fixInfo": { + "lineNumber": 1, + "deleteCount": -1 + } + }, + { + "fixInfo": { + "lineNumber": 2, + "deleteCount": -1 + } + } + ], + "" + ], + [ + "Hello world", + [ + { + "fixInfo": { + "lineNumber": 1, + "insertText": "aa" + } + }, + { + "fixInfo": { + "lineNumber": 1, + "insertText": "b" + } + } + ], + "aaHello world" + ], + [ + "Hello world", + [ + { + "fixInfo": { + "lineNumber": 1, + "insertText": "a" + } + }, + { + "fixInfo": { + "lineNumber": 1, + "insertText": "bb" + } + } + ], + "bbHello world" + ], + [ + "Hello world", + [ + { + "fixInfo": { + "lineNumber": 1, + "editColumn": 6, + "insertText": " big" + } + }, + { + "fixInfo": { + "lineNumber": 1, + "editColumn": 7, + "deleteCount": 1 + } + } + ], + "Hello big orld" + ], + [ + "Hello world", + [ + { + "fixInfo": { + "lineNumber": 1, + "editColumn": 8, + "deleteCount": 2 + } + }, + { + "fixInfo": { + "lineNumber": 1, + "editColumn": 7, + "deleteCount": 2 + } + } + ], + "Hello wld" + ], + [ + "Hello world", + [ + { + "fixInfo": { + "lineNumber": 1, + "editColumn": 7, + "deleteCount": 2 + } + }, + { + "fixInfo": { + "lineNumber": 1, + "editColumn": 8, + "deleteCount": 2 + } + } + ], + "Hello wld" + ], + [ + "Hello world", + [ + { + "fixInfo": { + "lineNumber": 1, + "editColumn": 7, + "deleteCount": 1 + } + }, + { + "fixInfo": { + "lineNumber": 1, + "editColumn": 7, + "insertText": "z" + } + } + ], + "Hello zworld" + ], + [ + "Hello world", + [ + { + "fixInfo": { + "lineNumber": 1, + "editColumn": 7, + "insertText": "z" + } + }, + { + "fixInfo": { + "lineNumber": 1, + "editColumn": 7, + "deleteCount": 1 + } + } + ], + "Hello zworld" ] ]; testCases.forEach((testCase) => {