Promote applyFix and applyFixes helpers into core library.

This commit is contained in:
David Anson 2024-10-06 17:24:44 -07:00
parent e0219411c6
commit 4e30462216
13 changed files with 898 additions and 823 deletions

View file

@ -768,6 +768,44 @@ Type: `Object`
Configuration object.
### Fixing
Rules that can be fixed automatically include a `fixInfo` property which is
outlined in the [documentation for custom rules](doc/CustomRules.md#authoring).
To apply fixes consistently, the `applyFix`/`applyFixes` methods may be used:
```javascript
/**
* Applies the specified fix to a Markdown content line.
*
* @param {string} line Line of Markdown content.
* @param {RuleOnErrorFixInfo} fixInfo RuleOnErrorFixInfo instance.
* @param {string} [lineEnding] Line ending to use.
* @returns {string | null} Fixed content or null if deleted.
*/
function applyFix(line, fixInfo, lineEnding = "\n") { ... }
/**
* Applies as many of the specified fixes as possible to Markdown content.
*
* @param {string} input Lines of Markdown content.
* @param {RuleOnErrorInfo[]} errors RuleOnErrorInfo instances.
* @returns {string} Fixed content.
*/
function applyFixes(input, errors) { ... }
```
Invoking `applyFixes` with the results of a call to lint can be done like so:
```javascript
const { "sync": markdownlintSync, applyFixes } = require("markdownlint");
function fixMarkdownlintViolations(content) {
const fixResults = markdownlintSync({ strings: { content } });
return applyFixes(content, fixResults.content);
}
```
## Usage
Invoke `markdownlint` and use the `result` object's `toString` method:
@ -934,14 +972,6 @@ bad.md: 1: MD041/first-line-heading/first-line-h1 First line in a file should be
Use --force to continue.
```
### Fixing
Rules that can be fixed automatically include a `fixInfo` property which is
outlined in the [documentation for custom rules](doc/CustomRules.md#authoring).
To apply those fixes more easily, the `applyFixes` method in
[markdownlint-rule-helpers](helpers/README.md#applying-recommended-fixes) may
be used.
## Browser
`markdownlint` also works in the browser.

View file

@ -4,7 +4,6 @@
// Dependencies
var markdownit = globalThis.markdownit;
var markdownlint = globalThis.markdownlint.library;
var helpers = globalThis.markdownlint.helpers;
var micromark = globalThis.micromarkBrowser;
var micromarkHtml = globalThis.micromarkHtmlBrowser;
@ -184,7 +183,7 @@
var errors = e.shiftKey ?
allLintErrors :
[ JSON.parse(decodeURIComponent(e.target.target)) ];
var fixed = helpers.applyFixes(markdown.value, errors);
var fixed = markdownlint.applyFixes(markdown.value, errors);
markdown.value = fixed;
onMarkdownInput();
e.preventDefault();

View file

@ -22,17 +22,7 @@ module.exports.newLineRe = newLineRe;
module.exports.nextLinesRe = nextLinesRe;
/** @typedef {import("../lib/markdownlint.js").RuleOnError} RuleOnError */
/** @typedef {import("../lib/markdownlint.js").RuleOnErrorInfo} RuleOnErrorInfo */
/** @typedef {import("../lib/markdownlint.js").RuleOnErrorFixInfo} RuleOnErrorFixInfo */
/**
* RuleOnErrorInfo with common optional properties filled in.
*
* @typedef {Object} RuleOnErrorFixInfoNormalized
* @property {number} lineNumber Line number (1-based).
* @property {number} editColumn Column of the fix (1-based).
* @property {number} deleteCount Count of characters to delete.
* @property {string} insertText Text to insert (after deleting).
*/
// Regular expression for matching common front matter (YAML and TOML)
module.exports.frontMatterRe =
@ -566,123 +556,6 @@ function getPreferredLineEnding(input, os) {
}
module.exports.getPreferredLineEnding = getPreferredLineEnding;
/**
* Normalizes the fields of a RuleOnErrorFixInfo instance.
*
* @param {RuleOnErrorFixInfo} fixInfo RuleOnErrorFixInfo instance.
* @param {number} [lineNumber] Line number.
* @returns {RuleOnErrorFixInfoNormalized} Normalized RuleOnErrorFixInfo instance.
*/
function normalizeFixInfo(fixInfo, lineNumber = 0) {
return {
"lineNumber": fixInfo.lineNumber || lineNumber,
"editColumn": fixInfo.editColumn || 1,
"deleteCount": fixInfo.deleteCount || 0,
"insertText": fixInfo.insertText || ""
};
}
/**
* Fixes the specified error on a line of Markdown content.
*
* @param {string} line Line of Markdown content.
* @param {RuleOnErrorFixInfo} fixInfo RuleOnErrorFixInfo instance.
* @param {string} [lineEnding] Line ending to use.
* @returns {string | null} Fixed content.
*/
function applyFix(line, fixInfo, lineEnding) {
const { editColumn, deleteCount, insertText } = normalizeFixInfo(fixInfo);
const editIndex = editColumn - 1;
return (deleteCount === -1) ?
null :
line.slice(0, editIndex) +
insertText.replace(/\n/g, lineEnding || "\n") +
line.slice(editIndex + deleteCount);
}
module.exports.applyFix = applyFix;
/**
* Applies as many fixes as possible to Markdown content.
*
* @param {string} input Lines of Markdown content.
* @param {RuleOnErrorInfo[]} errors RuleOnErrorInfo instances.
* @returns {string} Corrected content.
*/
function applyFixes(input, errors) {
const lineEnding = getPreferredLineEnding(input, __webpack_require__(/*! node:os */ "?0176"));
const lines = input.split(newLineRe);
// Normalize fixInfo objects
let fixInfos = errors
.filter((error) => error.fixInfo)
// @ts-ignore
.map((error) => normalizeFixInfo(error.fixInfo, error.lineNumber));
// Sort bottom-to-top, line-deletes last, right-to-left, long-to-short
fixInfos.sort((a, b) => {
const aDeletingLine = (a.deleteCount === -1);
const bDeletingLine = (b.deleteCount === -1);
return (
(b.lineNumber - a.lineNumber) ||
(aDeletingLine ? 1 : (bDeletingLine ? -1 : 0)) ||
(b.editColumn - a.editColumn) ||
(b.insertText.length - a.insertText.length)
);
});
// Remove duplicate entries (needed for following collapse step)
// eslint-disable-next-line jsdoc/valid-types
/** @type RuleOnErrorFixInfo */
let lastFixInfo = {};
fixInfos = fixInfos.filter((fixInfo) => {
const unique = (
(fixInfo.lineNumber !== lastFixInfo.lineNumber) ||
(fixInfo.editColumn !== lastFixInfo.editColumn) ||
(fixInfo.deleteCount !== lastFixInfo.deleteCount) ||
(fixInfo.insertText !== lastFixInfo.insertText)
);
lastFixInfo = fixInfo;
return unique;
});
// Collapse insert/no-delete and no-insert/delete for same line/column
lastFixInfo = {
"lineNumber": -1
};
for (const fixInfo of fixInfos) {
if (
(fixInfo.lineNumber === lastFixInfo.lineNumber) &&
(fixInfo.editColumn === lastFixInfo.editColumn) &&
!fixInfo.insertText &&
(fixInfo.deleteCount > 0) &&
lastFixInfo.insertText &&
!lastFixInfo.deleteCount) {
fixInfo.insertText = lastFixInfo.insertText;
lastFixInfo.lineNumber = 0;
}
lastFixInfo = fixInfo;
}
fixInfos = fixInfos.filter((fixInfo) => fixInfo.lineNumber);
// Apply all (remaining/updated) fixes
let lastLineIndex = -1;
let lastEditIndex = -1;
for (const fixInfo of fixInfos) {
const { lineNumber, editColumn, deleteCount } = fixInfo;
const lineIndex = lineNumber - 1;
const editIndex = editColumn - 1;
if (
(lineIndex !== lastLineIndex) ||
(deleteCount === -1) ||
((editIndex + deleteCount) <=
(lastEditIndex - ((deleteCount > 0) ? 0 : 1)))
) {
// @ts-ignore
lines[lineIndex] = applyFix(lines[lineIndex], fixInfo, lineEnding);
}
lastLineIndex = lineIndex;
lastEditIndex = editIndex;
}
// Return corrected input
return lines.filter((line) => line !== null).join(lineEnding);
}
module.exports.applyFixes = applyFixes;
/**
* Expands a path with a tilde to an absolute path.
*
@ -766,16 +639,6 @@ module.exports = micromarkBrowser;
/***/ }),
/***/ "?0176":
/*!*************************!*\
!*** node:os (ignored) ***!
\*************************/
/***/ (() => {
/* (ignored) */
/***/ }),
/***/ "?d0ee":
/*!*************************!*\
!*** node:fs (ignored) ***!
@ -2849,6 +2712,119 @@ function readConfigSync(file, parsers, fs) {
return config;
}
/**
* Normalizes the fields of a RuleOnErrorFixInfo instance.
*
* @param {RuleOnErrorFixInfo} fixInfo RuleOnErrorFixInfo instance.
* @param {number} [lineNumber] Line number.
* @returns {RuleOnErrorFixInfoNormalized} Normalized RuleOnErrorFixInfo instance.
*/
function normalizeFixInfo(fixInfo, lineNumber = 0) {
return {
"lineNumber": fixInfo.lineNumber || lineNumber,
"editColumn": fixInfo.editColumn || 1,
"deleteCount": fixInfo.deleteCount || 0,
"insertText": fixInfo.insertText || ""
};
}
/**
* Applies the specified fix to a Markdown content line.
*
* @param {string} line Line of Markdown content.
* @param {RuleOnErrorFixInfo} fixInfo RuleOnErrorFixInfo instance.
* @param {string} [lineEnding] Line ending to use.
* @returns {string | null} Fixed content or null if deleted.
*/
function applyFix(line, fixInfo, lineEnding = "\n") {
const { editColumn, deleteCount, insertText } = normalizeFixInfo(fixInfo);
const editIndex = editColumn - 1;
return (deleteCount === -1) ?
null :
line.slice(0, editIndex) + insertText.replace(/\n/g, lineEnding) + line.slice(editIndex + deleteCount);
}
/**
* Applies as many of the specified fixes as possible to Markdown content.
*
* @param {string} input Lines of Markdown content.
* @param {RuleOnErrorInfo[]} errors RuleOnErrorInfo instances.
* @returns {string} Fixed content.
*/
function applyFixes(input, errors) {
const lineEnding = helpers.getPreferredLineEnding(input, __webpack_require__(/*! node:os */ "?e6c4"));
const lines = input.split(helpers.newLineRe);
// Normalize fixInfo objects
let fixInfos = errors
.filter((error) => error.fixInfo)
// @ts-ignore
.map((error) => normalizeFixInfo(error.fixInfo, error.lineNumber));
// Sort bottom-to-top, line-deletes last, right-to-left, long-to-short
fixInfos.sort((a, b) => {
const aDeletingLine = (a.deleteCount === -1);
const bDeletingLine = (b.deleteCount === -1);
return (
(b.lineNumber - a.lineNumber) ||
(aDeletingLine ? 1 : (bDeletingLine ? -1 : 0)) ||
(b.editColumn - a.editColumn) ||
(b.insertText.length - a.insertText.length)
);
});
// Remove duplicate entries (needed for following collapse step)
// eslint-disable-next-line jsdoc/valid-types
/** @type RuleOnErrorFixInfo */
let lastFixInfo = {};
fixInfos = fixInfos.filter((fixInfo) => {
const unique = (
(fixInfo.lineNumber !== lastFixInfo.lineNumber) ||
(fixInfo.editColumn !== lastFixInfo.editColumn) ||
(fixInfo.deleteCount !== lastFixInfo.deleteCount) ||
(fixInfo.insertText !== lastFixInfo.insertText)
);
lastFixInfo = fixInfo;
return unique;
});
// Collapse insert/no-delete and no-insert/delete for same line/column
lastFixInfo = {
"lineNumber": -1
};
for (const fixInfo of fixInfos) {
if (
(fixInfo.lineNumber === lastFixInfo.lineNumber) &&
(fixInfo.editColumn === lastFixInfo.editColumn) &&
!fixInfo.insertText &&
(fixInfo.deleteCount > 0) &&
lastFixInfo.insertText &&
!lastFixInfo.deleteCount) {
fixInfo.insertText = lastFixInfo.insertText;
lastFixInfo.lineNumber = 0;
}
lastFixInfo = fixInfo;
}
fixInfos = fixInfos.filter((fixInfo) => fixInfo.lineNumber);
// Apply all (remaining/updated) fixes
let lastLineIndex = -1;
let lastEditIndex = -1;
for (const fixInfo of fixInfos) {
const { lineNumber, editColumn, deleteCount } = fixInfo;
const lineIndex = lineNumber - 1;
const editIndex = editColumn - 1;
if (
(lineIndex !== lastLineIndex) ||
(deleteCount === -1) ||
((editIndex + deleteCount) <=
(lastEditIndex - ((deleteCount > 0) ? 0 : 1)))
) {
// @ts-ignore
lines[lineIndex] = applyFix(lines[lineIndex], fixInfo, lineEnding);
}
lastLineIndex = lineIndex;
lastEditIndex = editIndex;
}
// Return corrected input
return lines.filter((line) => line !== null).join(lineEnding);
}
/**
* Gets the (semantic) version of the library.
*
@ -2868,6 +2844,8 @@ markdownlint.promises = {
"extendConfig": extendConfigPromise,
"readConfig": readConfigPromise
};
markdownlint.applyFix = applyFix;
markdownlint.applyFixes = applyFixes;
module.exports = markdownlint;
// Type declarations
@ -2986,6 +2964,16 @@ module.exports = markdownlint;
* @property {string} [insertText] Text to insert (after deleting).
*/
/**
* RuleOnErrorInfo with all optional properties present.
*
* @typedef {Object} RuleOnErrorFixInfoNormalized
* @property {number} lineNumber Line number (1-based).
* @property {number} editColumn Column of the fix (1-based).
* @property {number} deleteCount Count of characters to delete.
* @property {string} insertText Text to insert (after deleting).
*/
/**
* Rule definition.
*

View file

@ -166,6 +166,36 @@ markdownlint(options, assertLintResultsCallback);
assertLintResultsCallback(null, await markdownlint.promises.markdownlint(options));
})();
assert.equal(
markdownlint.applyFix(
"# Fixing\n",
{
"insertText": "Head",
"editColumn": 3,
"deleteCount": 3
},
"\n"
),
"# Heading\n"
);
assert.equal(
markdownlint.applyFixes(
"# Fixing\n",
[
{
"lineNumber": 1,
"fixInfo": {
"insertText": "Head",
"editColumn": 3,
"deleteCount": 3
}
}
]
),
"# Heading\n"
);
const configuration: markdownlint.Configuration = {
"custom-rule": true,
"no-hard-tabs": false,

View file

@ -17,22 +17,6 @@ change from release to release. There are brief descriptive comments above each
function, but no [JSDoc][jsdoc] annotations. That said, some of what's here will
be useful to custom rule authors and may avoid duplicating code.
## Examples
### Applying Recommended Fixes
```javascript
const { "sync": markdownlintSync } = require("markdownlint");
const markdownlintRuleHelpers = require("markdownlint-rule-helpers");
function fixMarkdownlintViolations(content) {
const fixResults = markdownlintSync({ strings: { content } });
return markdownlintRuleHelpers.applyFixes(content, fixResults.content);
}
```
See also: [`markdownlint` built-in rule implementations][lib].
## Tests
*None* - The entire body of code is tested to 100% coverage by the core
@ -40,7 +24,6 @@ See also: [`markdownlint` built-in rule implementations][lib].
[custom-rules]: https://github.com/DavidAnson/markdownlint/blob/v0.35.0/doc/CustomRules.md
[jsdoc]: https://en.m.wikipedia.org/wiki/JSDoc
[lib]: https://github.com/DavidAnson/markdownlint/tree/v0.35.0/lib
[markdown]: https://en.wikipedia.org/wiki/Markdown
[markdownlint]: https://github.com/DavidAnson/markdownlint
[rules]: https://github.com/DavidAnson/markdownlint/blob/v0.35.0/doc/Rules.md

View file

@ -10,17 +10,7 @@ module.exports.newLineRe = newLineRe;
module.exports.nextLinesRe = nextLinesRe;
/** @typedef {import("../lib/markdownlint.js").RuleOnError} RuleOnError */
/** @typedef {import("../lib/markdownlint.js").RuleOnErrorInfo} RuleOnErrorInfo */
/** @typedef {import("../lib/markdownlint.js").RuleOnErrorFixInfo} RuleOnErrorFixInfo */
/**
* RuleOnErrorInfo with common optional properties filled in.
*
* @typedef {Object} RuleOnErrorFixInfoNormalized
* @property {number} lineNumber Line number (1-based).
* @property {number} editColumn Column of the fix (1-based).
* @property {number} deleteCount Count of characters to delete.
* @property {string} insertText Text to insert (after deleting).
*/
// Regular expression for matching common front matter (YAML and TOML)
module.exports.frontMatterRe =
@ -554,123 +544,6 @@ function getPreferredLineEnding(input, os) {
}
module.exports.getPreferredLineEnding = getPreferredLineEnding;
/**
* Normalizes the fields of a RuleOnErrorFixInfo instance.
*
* @param {RuleOnErrorFixInfo} fixInfo RuleOnErrorFixInfo instance.
* @param {number} [lineNumber] Line number.
* @returns {RuleOnErrorFixInfoNormalized} Normalized RuleOnErrorFixInfo instance.
*/
function normalizeFixInfo(fixInfo, lineNumber = 0) {
return {
"lineNumber": fixInfo.lineNumber || lineNumber,
"editColumn": fixInfo.editColumn || 1,
"deleteCount": fixInfo.deleteCount || 0,
"insertText": fixInfo.insertText || ""
};
}
/**
* Fixes the specified error on a line of Markdown content.
*
* @param {string} line Line of Markdown content.
* @param {RuleOnErrorFixInfo} fixInfo RuleOnErrorFixInfo instance.
* @param {string} [lineEnding] Line ending to use.
* @returns {string | null} Fixed content.
*/
function applyFix(line, fixInfo, lineEnding) {
const { editColumn, deleteCount, insertText } = normalizeFixInfo(fixInfo);
const editIndex = editColumn - 1;
return (deleteCount === -1) ?
null :
line.slice(0, editIndex) +
insertText.replace(/\n/g, lineEnding || "\n") +
line.slice(editIndex + deleteCount);
}
module.exports.applyFix = applyFix;
/**
* Applies as many fixes as possible to Markdown content.
*
* @param {string} input Lines of Markdown content.
* @param {RuleOnErrorInfo[]} errors RuleOnErrorInfo instances.
* @returns {string} Corrected content.
*/
function applyFixes(input, errors) {
const lineEnding = getPreferredLineEnding(input, require("node:os"));
const lines = input.split(newLineRe);
// Normalize fixInfo objects
let fixInfos = errors
.filter((error) => error.fixInfo)
// @ts-ignore
.map((error) => normalizeFixInfo(error.fixInfo, error.lineNumber));
// Sort bottom-to-top, line-deletes last, right-to-left, long-to-short
fixInfos.sort((a, b) => {
const aDeletingLine = (a.deleteCount === -1);
const bDeletingLine = (b.deleteCount === -1);
return (
(b.lineNumber - a.lineNumber) ||
(aDeletingLine ? 1 : (bDeletingLine ? -1 : 0)) ||
(b.editColumn - a.editColumn) ||
(b.insertText.length - a.insertText.length)
);
});
// Remove duplicate entries (needed for following collapse step)
// eslint-disable-next-line jsdoc/valid-types
/** @type RuleOnErrorFixInfo */
let lastFixInfo = {};
fixInfos = fixInfos.filter((fixInfo) => {
const unique = (
(fixInfo.lineNumber !== lastFixInfo.lineNumber) ||
(fixInfo.editColumn !== lastFixInfo.editColumn) ||
(fixInfo.deleteCount !== lastFixInfo.deleteCount) ||
(fixInfo.insertText !== lastFixInfo.insertText)
);
lastFixInfo = fixInfo;
return unique;
});
// Collapse insert/no-delete and no-insert/delete for same line/column
lastFixInfo = {
"lineNumber": -1
};
for (const fixInfo of fixInfos) {
if (
(fixInfo.lineNumber === lastFixInfo.lineNumber) &&
(fixInfo.editColumn === lastFixInfo.editColumn) &&
!fixInfo.insertText &&
(fixInfo.deleteCount > 0) &&
lastFixInfo.insertText &&
!lastFixInfo.deleteCount) {
fixInfo.insertText = lastFixInfo.insertText;
lastFixInfo.lineNumber = 0;
}
lastFixInfo = fixInfo;
}
fixInfos = fixInfos.filter((fixInfo) => fixInfo.lineNumber);
// Apply all (remaining/updated) fixes
let lastLineIndex = -1;
let lastEditIndex = -1;
for (const fixInfo of fixInfos) {
const { lineNumber, editColumn, deleteCount } = fixInfo;
const lineIndex = lineNumber - 1;
const editIndex = editColumn - 1;
if (
(lineIndex !== lastLineIndex) ||
(deleteCount === -1) ||
((editIndex + deleteCount) <=
(lastEditIndex - ((deleteCount > 0) ? 0 : 1)))
) {
// @ts-ignore
lines[lineIndex] = applyFix(lines[lineIndex], fixInfo, lineEnding);
}
lastLineIndex = lineIndex;
lastEditIndex = editIndex;
}
// Return corrected input
return lines.filter((line) => line !== null).join(lineEnding);
}
module.exports.applyFixes = applyFixes;
/**
* Expands a path with a tilde to an absolute path.
*

40
lib/markdownlint.d.ts vendored
View file

@ -8,7 +8,7 @@ export = markdownlint;
*/
declare function markdownlint(options: Options | null, callback: LintCallback): void;
declare namespace markdownlint {
export { markdownlintSync as sync, readConfig, readConfigSync, getVersion, promises, RuleFunction, RuleParams, MarkdownParsers, ParserMarkdownIt, ParserMicromark, MarkdownItToken, MicromarkTokenType, MicromarkToken, RuleOnError, RuleOnErrorInfo, RuleOnErrorFixInfo, Rule, Options, Plugin, ToStringCallback, LintResults, LintError, FixInfo, LintContentCallback, LintCallback, Configuration, ConfigurationStrict, RuleConfiguration, ConfigurationParser, ReadConfigCallback, ResolveConfigExtendsCallback };
export { markdownlintSync as sync, readConfig, readConfigSync, getVersion, promises, applyFix, applyFixes, RuleFunction, RuleParams, MarkdownParsers, ParserMarkdownIt, ParserMicromark, MarkdownItToken, MicromarkTokenType, MicromarkToken, RuleOnError, RuleOnErrorInfo, RuleOnErrorFixInfo, RuleOnErrorFixInfoNormalized, Rule, Options, Plugin, ToStringCallback, LintResults, LintError, FixInfo, LintContentCallback, LintCallback, Configuration, ConfigurationStrict, RuleConfiguration, ConfigurationParser, ReadConfigCallback, ResolveConfigExtendsCallback };
}
/**
* Lint specified Markdown files synchronously.
@ -49,6 +49,23 @@ declare namespace promises {
export { extendConfigPromise as extendConfig };
export { readConfigPromise as readConfig };
}
/**
* Applies the specified fix to a Markdown content line.
*
* @param {string} line Line of Markdown content.
* @param {RuleOnErrorFixInfo} fixInfo RuleOnErrorFixInfo instance.
* @param {string} [lineEnding] Line ending to use.
* @returns {string | null} Fixed content or null if deleted.
*/
declare function applyFix(line: string, fixInfo: RuleOnErrorFixInfo, lineEnding?: string): string | null;
/**
* Applies as many of the specified fixes as possible to Markdown content.
*
* @param {string} input Lines of Markdown content.
* @param {RuleOnErrorInfo[]} errors RuleOnErrorInfo instances.
* @returns {string} Fixed content.
*/
declare function applyFixes(input: string, errors: RuleOnErrorInfo[]): string;
/**
* Function to implement rule logic.
*/
@ -270,6 +287,27 @@ type RuleOnErrorFixInfo = {
*/
insertText?: string;
};
/**
* RuleOnErrorInfo with all optional properties present.
*/
type RuleOnErrorFixInfoNormalized = {
/**
* Line number (1-based).
*/
lineNumber: number;
/**
* Column of the fix (1-based).
*/
editColumn: number;
/**
* Count of characters to delete.
*/
deleteCount: number;
/**
* Text to insert (after deleting).
*/
insertText: string;
};
/**
* Rule definition.
*/

View file

@ -1204,6 +1204,119 @@ function readConfigSync(file, parsers, fs) {
return config;
}
/**
* Normalizes the fields of a RuleOnErrorFixInfo instance.
*
* @param {RuleOnErrorFixInfo} fixInfo RuleOnErrorFixInfo instance.
* @param {number} [lineNumber] Line number.
* @returns {RuleOnErrorFixInfoNormalized} Normalized RuleOnErrorFixInfo instance.
*/
function normalizeFixInfo(fixInfo, lineNumber = 0) {
return {
"lineNumber": fixInfo.lineNumber || lineNumber,
"editColumn": fixInfo.editColumn || 1,
"deleteCount": fixInfo.deleteCount || 0,
"insertText": fixInfo.insertText || ""
};
}
/**
* Applies the specified fix to a Markdown content line.
*
* @param {string} line Line of Markdown content.
* @param {RuleOnErrorFixInfo} fixInfo RuleOnErrorFixInfo instance.
* @param {string} [lineEnding] Line ending to use.
* @returns {string | null} Fixed content or null if deleted.
*/
function applyFix(line, fixInfo, lineEnding = "\n") {
const { editColumn, deleteCount, insertText } = normalizeFixInfo(fixInfo);
const editIndex = editColumn - 1;
return (deleteCount === -1) ?
null :
line.slice(0, editIndex) + insertText.replace(/\n/g, lineEnding) + line.slice(editIndex + deleteCount);
}
/**
* Applies as many of the specified fixes as possible to Markdown content.
*
* @param {string} input Lines of Markdown content.
* @param {RuleOnErrorInfo[]} errors RuleOnErrorInfo instances.
* @returns {string} Fixed content.
*/
function applyFixes(input, errors) {
const lineEnding = helpers.getPreferredLineEnding(input, require("node:os"));
const lines = input.split(helpers.newLineRe);
// Normalize fixInfo objects
let fixInfos = errors
.filter((error) => error.fixInfo)
// @ts-ignore
.map((error) => normalizeFixInfo(error.fixInfo, error.lineNumber));
// Sort bottom-to-top, line-deletes last, right-to-left, long-to-short
fixInfos.sort((a, b) => {
const aDeletingLine = (a.deleteCount === -1);
const bDeletingLine = (b.deleteCount === -1);
return (
(b.lineNumber - a.lineNumber) ||
(aDeletingLine ? 1 : (bDeletingLine ? -1 : 0)) ||
(b.editColumn - a.editColumn) ||
(b.insertText.length - a.insertText.length)
);
});
// Remove duplicate entries (needed for following collapse step)
// eslint-disable-next-line jsdoc/valid-types
/** @type RuleOnErrorFixInfo */
let lastFixInfo = {};
fixInfos = fixInfos.filter((fixInfo) => {
const unique = (
(fixInfo.lineNumber !== lastFixInfo.lineNumber) ||
(fixInfo.editColumn !== lastFixInfo.editColumn) ||
(fixInfo.deleteCount !== lastFixInfo.deleteCount) ||
(fixInfo.insertText !== lastFixInfo.insertText)
);
lastFixInfo = fixInfo;
return unique;
});
// Collapse insert/no-delete and no-insert/delete for same line/column
lastFixInfo = {
"lineNumber": -1
};
for (const fixInfo of fixInfos) {
if (
(fixInfo.lineNumber === lastFixInfo.lineNumber) &&
(fixInfo.editColumn === lastFixInfo.editColumn) &&
!fixInfo.insertText &&
(fixInfo.deleteCount > 0) &&
lastFixInfo.insertText &&
!lastFixInfo.deleteCount) {
fixInfo.insertText = lastFixInfo.insertText;
lastFixInfo.lineNumber = 0;
}
lastFixInfo = fixInfo;
}
fixInfos = fixInfos.filter((fixInfo) => fixInfo.lineNumber);
// Apply all (remaining/updated) fixes
let lastLineIndex = -1;
let lastEditIndex = -1;
for (const fixInfo of fixInfos) {
const { lineNumber, editColumn, deleteCount } = fixInfo;
const lineIndex = lineNumber - 1;
const editIndex = editColumn - 1;
if (
(lineIndex !== lastLineIndex) ||
(deleteCount === -1) ||
((editIndex + deleteCount) <=
(lastEditIndex - ((deleteCount > 0) ? 0 : 1)))
) {
// @ts-ignore
lines[lineIndex] = applyFix(lines[lineIndex], fixInfo, lineEnding);
}
lastLineIndex = lineIndex;
lastEditIndex = editIndex;
}
// Return corrected input
return lines.filter((line) => line !== null).join(lineEnding);
}
/**
* Gets the (semantic) version of the library.
*
@ -1223,6 +1336,8 @@ markdownlint.promises = {
"extendConfig": extendConfigPromise,
"readConfig": readConfigPromise
};
markdownlint.applyFix = applyFix;
markdownlint.applyFixes = applyFixes;
module.exports = markdownlint;
// Type declarations
@ -1341,6 +1456,16 @@ module.exports = markdownlint;
* @property {string} [insertText] Text to insert (after deleting).
*/
/**
* RuleOnErrorInfo with all optional properties present.
*
* @typedef {Object} RuleOnErrorFixInfoNormalized
* @property {number} lineNumber Line number (1-based).
* @property {number} editColumn Column of the fix (1-based).
* @property {number} deleteCount Count of characters to delete.
* @property {string} insertText Text to insert (after deleting).
*/
/**
* Rule definition.
*

View file

@ -53,7 +53,7 @@
"lint-test-repos": "ava --timeout=10m test/markdownlint-test-repos-*.js",
"serial-config-docs": "npm run build-config && npm run build-docs",
"serial-declaration-demo": "npm run build-declaration && npm-run-all --continue-on-error --parallel build-demo test-declaration",
"test": "ava --timeout=30s test/markdownlint-test.js test/markdownlint-test-config.js test/markdownlint-test-custom-rules.js test/markdownlint-test-helpers.js test/markdownlint-test-micromark.mjs test/markdownlint-test-result-object.js test/markdownlint-test-scenarios.js helpers/test.cjs",
"test": "ava --timeout=30s test/markdownlint-test.js test/markdownlint-test-config.js test/markdownlint-test-custom-rules.js test/markdownlint-test-fixes.js test/markdownlint-test-helpers.js test/markdownlint-test-micromark.mjs test/markdownlint-test-result-object.js test/markdownlint-test-scenarios.js helpers/test.cjs",
"test-cover": "c8 --100 npm test",
"test-declaration": "cd example/typescript && tsc --module nodenext && tsc --module commonjs && node type-check.js",
"test-extra": "ava --timeout=10m test/markdownlint-test-extra-parse.js test/markdownlint-test-extra-type.js",

View file

@ -0,0 +1,532 @@
// @ts-check
"use strict";
const test = require("ava").default;
const markdownlint = require("../lib/markdownlint");
test("applyFix", (t) => {
t.plan(4);
const testCases = [
[
"Hello world.",
{
"editColumn": 12,
"deleteCount": 1
},
undefined,
"Hello world"
],
[
"Hello world.",
{
"editColumn": 13,
"insertText": "\n"
},
undefined,
"Hello world.\n"
],
[
"Hello world.",
{
"editColumn": 13,
"insertText": "\n"
},
"\n",
"Hello world.\n"
],
[
"Hello world.",
{
"editColumn": 13,
"insertText": "\n"
},
"\r\n",
"Hello world.\r\n"
]
];
for (const testCase of testCases) {
const [ line, fixInfo, lineEnding, expected ] = testCase;
// @ts-ignore
const actual = markdownlint.applyFix(line, fixInfo, lineEnding);
t.is(actual, String(expected), "Incorrect fix applied.");
}
});
test("applyFixes", (t) => {
t.plan(30);
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."
],
[
"Hello\nworld",
[
{
"lineNumber": 1,
"fixInfo": {
"deleteCount": -1
}
}
],
"world"
],
[
"Hello\nworld",
[
{
"lineNumber": 2,
"fixInfo": {
"deleteCount": -1
}
}
],
"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,
"insertText": "z"
}
}
],
"Hello zorld"
],
[
"Hello world",
[
{
"fixInfo": {
"lineNumber": 1,
"editColumn": 7,
"deleteCount": 1
}
},
{
"fixInfo": {
"lineNumber": 1,
"editColumn": 7,
"insertText": "z"
}
}
],
"Hello zorld"
],
[
"Hello world",
[
{
"fixInfo": {
"lineNumber": 1,
"editColumn": 7,
"insertText": "z"
}
},
{
"fixInfo": {
"lineNumber": 1,
"editColumn": 7,
"deleteCount": 1
}
}
],
"Hello zorld"
],
[
"Hello\nworld\nhello\rworld",
[
{
"fixInfo": {
"lineNumber": 4,
"editColumn": 6,
"insertText": "\n"
}
}
],
"Hello\nworld\nhello\nworld\n"
],
[
"Hello\r\nworld\r\nhello\nworld",
[
{
"fixInfo": {
"lineNumber": 4,
"editColumn": 6,
"insertText": "\n"
}
}
],
"Hello\r\nworld\r\nhello\r\nworld\r\n"
],
[
"Hello\rworld\rhello\nworld",
[
{
"fixInfo": {
"lineNumber": 4,
"editColumn": 6,
"insertText": "\n"
}
}
],
"Hello\rworld\rhello\rworld\r"
],
[
"Hello\r\nworld",
[
{
"lineNumber": 2,
"fixInfo": {
"editColumn": 6,
"insertText": "\n\n"
}
}
],
"Hello\r\nworld\r\n\r\n"
],
[
"Hello world",
[
{
"lineNumber": 1,
"fixInfo": {
"insertText": "x"
}
},
{
"lineNumber": 1,
"fixInfo": {
"deleteCount": -1
}
}
],
""
],
[
" hello world",
[
{
"lineNumber": 1,
"fixInfo": {
"editColumn": 1,
"deleteCount": 1
}
},
{
"lineNumber": 1,
"fixInfo": {
"editColumn": 2,
"deleteCount": 1,
"insertText": "H"
}
}
],
"Hello world"
]
];
for (const testCase of testCases) {
const [ input, errors, expected ] = testCase;
// @ts-ignore
const actual = markdownlint.applyFixes(input, errors);
t.is(actual, String(expected), "Incorrect fix applied.");
}
});

View file

@ -6,7 +6,8 @@ const os = require("node:os");
const path = require("node:path");
const test = require("ava").default;
const helpers = require("../helpers");
const { markdownlint } = require("../lib/markdownlint").promises;
const libMarkdownlint = require("../lib/markdownlint");
const { markdownlint } = libMarkdownlint.promises;
const { forEachInlineCodeSpan } = require("../lib/markdownit.cjs");
test("clearHtmlCommentTextValid", (t) => {
@ -352,532 +353,6 @@ test("getPreferredLineEnding", (t) => {
t.is(helpers.getPreferredLineEnding("", { "EOL": "custom" }), "custom");
});
test("applyFix", (t) => {
t.plan(4);
const testCases = [
[
"Hello world.",
{
"editColumn": 12,
"deleteCount": 1
},
undefined,
"Hello world"
],
[
"Hello world.",
{
"editColumn": 13,
"insertText": "\n"
},
undefined,
"Hello world.\n"
],
[
"Hello world.",
{
"editColumn": 13,
"insertText": "\n"
},
"\n",
"Hello world.\n"
],
[
"Hello world.",
{
"editColumn": 13,
"insertText": "\n"
},
"\r\n",
"Hello world.\r\n"
]
];
for (const testCase of testCases) {
const [ line, fixInfo, lineEnding, expected ] = testCase;
// @ts-ignore
const actual = helpers.applyFix(line, fixInfo, lineEnding);
t.is(actual, String(expected), "Incorrect fix applied.");
}
});
test("applyFixes", (t) => {
t.plan(30);
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."
],
[
"Hello\nworld",
[
{
"lineNumber": 1,
"fixInfo": {
"deleteCount": -1
}
}
],
"world"
],
[
"Hello\nworld",
[
{
"lineNumber": 2,
"fixInfo": {
"deleteCount": -1
}
}
],
"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,
"insertText": "z"
}
}
],
"Hello zorld"
],
[
"Hello world",
[
{
"fixInfo": {
"lineNumber": 1,
"editColumn": 7,
"deleteCount": 1
}
},
{
"fixInfo": {
"lineNumber": 1,
"editColumn": 7,
"insertText": "z"
}
}
],
"Hello zorld"
],
[
"Hello world",
[
{
"fixInfo": {
"lineNumber": 1,
"editColumn": 7,
"insertText": "z"
}
},
{
"fixInfo": {
"lineNumber": 1,
"editColumn": 7,
"deleteCount": 1
}
}
],
"Hello zorld"
],
[
"Hello\nworld\nhello\rworld",
[
{
"fixInfo": {
"lineNumber": 4,
"editColumn": 6,
"insertText": "\n"
}
}
],
"Hello\nworld\nhello\nworld\n"
],
[
"Hello\r\nworld\r\nhello\nworld",
[
{
"fixInfo": {
"lineNumber": 4,
"editColumn": 6,
"insertText": "\n"
}
}
],
"Hello\r\nworld\r\nhello\r\nworld\r\n"
],
[
"Hello\rworld\rhello\nworld",
[
{
"fixInfo": {
"lineNumber": 4,
"editColumn": 6,
"insertText": "\n"
}
}
],
"Hello\rworld\rhello\rworld\r"
],
[
"Hello\r\nworld",
[
{
"lineNumber": 2,
"fixInfo": {
"editColumn": 6,
"insertText": "\n\n"
}
}
],
"Hello\r\nworld\r\n\r\n"
],
[
"Hello world",
[
{
"lineNumber": 1,
"fixInfo": {
"insertText": "x"
}
},
{
"lineNumber": 1,
"fixInfo": {
"deleteCount": -1
}
}
],
""
],
[
" hello world",
[
{
"lineNumber": 1,
"fixInfo": {
"editColumn": 1,
"deleteCount": 1
}
},
{
"lineNumber": 1,
"fixInfo": {
"editColumn": 2,
"deleteCount": 1,
"insertText": "H"
}
}
],
"Hello world"
]
];
for (const testCase of testCases) {
const [ input, errors, expected ] = testCase;
// @ts-ignore
const actual = helpers.applyFixes(input, errors);
t.is(actual, String(expected), "Incorrect fix applied.");
}
});
test("expandTildePath", (t) => {
t.plan(17);
const homedir = os.homedir();

View file

@ -5,7 +5,9 @@
const fs = require("node:fs").promises;
const path = require("node:path");
const test = require("ava").default;
const { markdownlint } = require("../lib/markdownlint").promises;
const libMarkdownlint = require("../lib/markdownlint");
const { applyFixes, promises } = libMarkdownlint;
const { markdownlint } = promises;
const helpers = require("../helpers");
const constants = require("../lib/constants");
@ -82,7 +84,7 @@ function createTestForFile(file) {
}
t.deepEqual(actual, expected, "Too few or too many issues found.");
// Create snapshot
const fixed = helpers.applyFixes(content, errors)
const fixed = applyFixes(content, errors)
.replace(/\r\n/g, "\n");
t.snapshot({
errors,

View file

@ -686,8 +686,8 @@ test("readmeHeadings", (t) => new Promise((resolve) => {
"#### fs",
"#### callback",
"#### result",
"## Usage",
"### Fixing",
"## Usage",
"## Browser",
"## Examples",
"## Contributing",