Allow custom file system implementation to be passed when linting or reading configuration.

This commit is contained in:
David Anson 2021-08-12 19:38:03 -07:00
parent b10147f16b
commit 211f09afbc
6 changed files with 277 additions and 52 deletions

View file

@ -39,7 +39,7 @@
"max-depth": "off",
"max-lines": "off",
"max-lines-per-function": "off",
"max-params": ["error", 10],
"max-params": ["off"],
"max-statements": "off",
"multiline-comment-style": ["error", "separate-lines"],
"multiline-ternary": "off",

View file

@ -294,7 +294,8 @@ function markdownlint(options) { ... }
Type: `Object`
Configures the function.
Configures the function. All properties are optional, but at least one
of `files` or `strings` should be set to provide input.
##### options.customRules
@ -534,6 +535,16 @@ Each item in the top-level `Array` should be of the form:
[ require("markdown-it-plugin"), plugin_param_0, plugin_param_1, ... ]
```
##### options.fs
Type: `Object` implementing the [file system API](https://nodejs.org/api/fs.html)
In advanced scenarios, it may be desirable to bypass the default file system API.
If a custom file system implementation is provided, `markdownlint` will use that
instead of invoking `require("fs")`.
Note: The only methods called are `readFile` and `readFileSync`.
#### callback
Type: `Function` taking (`Error`, `Object`)
@ -566,10 +577,11 @@ Asynchronous API:
*
* @param {string} file Configuration file name.
* @param {ConfigurationParser[] | ReadConfigCallback} parsers Parsing function(s).
* @param {Object} [fs] File system implementation.
* @param {ReadConfigCallback} [callback] Callback (err, result) function.
* @returns {void}
*/
function readConfig(file, parsers, callback) { ... }
function readConfig(file, parsers, fs, callback) { ... }
```
Synchronous API:
@ -580,13 +592,14 @@ Synchronous API:
*
* @param {string} file Configuration file name.
* @param {ConfigurationParser[]} [parsers] Parsing function(s).
* @param {Object} [fs] File system implementation.
* @returns {Configuration} Configuration object.
*/
function readConfigSync(file, parsers) { ... }
function readConfigSync(file, parsers, fs) { ... }
```
Promise API (in the `promises` namespace like Node.js's
[`fs` Promises API](https://nodejs.org/api/fs.html#fs_fs_promises_api)):
[`fs` Promises API](https://nodejs.org/api/fs.html#fs_promises_api)):
```js
/**
@ -594,9 +607,10 @@ Promise API (in the `promises` namespace like Node.js's
*
* @param {string} file Configuration file name.
* @param {ConfigurationParser[]} [parsers] Parsing function(s).
* @param {Object} [fs] File system implementation.
* @returns {Promise<Configuration>} Configuration object.
*/
function readConfig(file, parsers) { ... }
function readConfig(file, parsers, fs) { ... }
```
#### file
@ -627,6 +641,16 @@ For example:
[ JSON.parse, require("toml").parse, require("js-yaml").load ]
```
#### fs
Type: *Optional* `Object` implementing the [file system API](https://nodejs.org/api/fs.html)
In advanced scenarios, it may be desirable to bypass the default file system API.
If a custom file system implementation is provided, `markdownlint` will use that
instead of invoking `require("fs")`.
Note: The only methods called are `readFile`, `readFileSync`, and `accessSync`.
#### callback
Type: `Function` taking (`Error`, `Object`)

View file

@ -813,7 +813,6 @@ var __spreadArray = (this && this.__spreadArray) || function (to, from) {
to[j] = from[i];
return to;
};
var fs = __webpack_require__(/*! fs */ "?ec0a");
var path = __webpack_require__(/*! path */ "?b85c");
var promisify = __webpack_require__(/*! util */ "?96a2").promisify;
var markdownIt = __webpack_require__(/*! markdown-it */ "markdown-it");
@ -1446,11 +1445,12 @@ function lintContent(ruleList, name, content, md, config, frontMatter, handleRul
* @param {boolean} handleRuleFailures Whether to handle exceptions in rules.
* @param {boolean} noInlineConfig Whether to allow inline configuration.
* @param {number} resultVersion Version of the LintResults object to return.
* @param {Object} fs File system implementation.
* @param {boolean} synchronous Whether to execute synchronously.
* @param {Function} callback Callback (err, result) function.
* @returns {void}
*/
function lintFile(ruleList, file, md, config, frontMatter, handleRuleFailures, noInlineConfig, resultVersion, synchronous, callback) {
function lintFile(ruleList, file, md, config, frontMatter, handleRuleFailures, noInlineConfig, resultVersion, fs, synchronous, callback) {
// eslint-disable-next-line jsdoc/require-jsdoc
function lintContentWrapper(err, content) {
if (err) {
@ -1460,7 +1460,6 @@ function lintFile(ruleList, file, md, config, frontMatter, handleRuleFailures, n
}
// Make a/synchronous call to read file
if (synchronous) {
// @ts-ignore
lintContentWrapper(null, fs.readFileSync(file, "utf8"));
}
else {
@ -1507,6 +1506,7 @@ function lintInput(options, synchronous, callback) {
// @ts-ignore
md.use.apply(md, plugin);
});
var fs = options.fs || __webpack_require__(/*! fs */ "?ec0a");
var results = newResults(ruleList);
var done = false;
// Linting of strings is always synchronous
@ -1526,7 +1526,7 @@ function lintInput(options, synchronous, callback) {
if (synchronous) {
// Lint files synchronously
while (!done && (syncItem = files.shift())) {
lintFile(ruleList, syncItem, md, config, frontMatter, handleRuleFailures, noInlineConfig, resultVersion, synchronous, syncCallback);
lintFile(ruleList, syncItem, md, config, frontMatter, handleRuleFailures, noInlineConfig, resultVersion, fs, synchronous, syncCallback);
}
return done || callback(null, results);
}
@ -1540,7 +1540,7 @@ function lintInput(options, synchronous, callback) {
}
else if (asyncItem) {
concurrency++;
lintFile(ruleList, asyncItem, md, config, frontMatter, handleRuleFailures, noInlineConfig, resultVersion, synchronous, function (err, result) {
lintFile(ruleList, asyncItem, md, config, frontMatter, handleRuleFailures, noInlineConfig, resultVersion, fs, synchronous, function (err, result) {
concurrency--;
if (err) {
done = true;
@ -1644,24 +1644,24 @@ function parseConfiguration(name, content, parsers) {
*
* @param {string} configFile Configuration file name.
* @param {string} referenceId Referenced identifier to resolve.
* @param {Object} fs File system implementation.
* @returns {string} Resolved path to file.
*/
function resolveConfigExtends(configFile, referenceId) {
function resolveConfigExtends(configFile, referenceId, fs) {
var configFileDirname = path.dirname(configFile);
var resolvedExtendsFile = path.resolve(configFileDirname, referenceId);
try {
if (fs.statSync(resolvedExtendsFile).isFile()) {
return resolvedExtendsFile;
}
fs.accessSync(resolvedExtendsFile);
return resolvedExtendsFile;
}
catch (_a) {
// If not a file or fs.statSync throws, try require.resolve
// Not a file, try require.resolve
}
try {
return dynamicRequire.resolve(referenceId, { "paths": [configFileDirname] });
}
catch (_b) {
// If require.resolve throws, return resolvedExtendsFile
// Unable to resolve, return resolvedExtendsFile
}
return resolvedExtendsFile;
}
@ -1671,14 +1671,24 @@ function resolveConfigExtends(configFile, referenceId) {
* @param {string} file Configuration file name.
* @param {ConfigurationParser[] | ReadConfigCallback} parsers Parsing
* function(s).
* @param {Object} [fs] File system implementation.
* @param {ReadConfigCallback} [callback] Callback (err, result) function.
* @returns {void}
*/
function readConfig(file, parsers, callback) {
function readConfig(file, parsers, fs, callback) {
if (!callback) {
// @ts-ignore
callback = parsers;
parsers = null;
if (fs) {
callback = fs;
fs = null;
}
else {
// @ts-ignore
callback = parsers;
parsers = null;
}
}
if (!fs) {
fs = __webpack_require__(/*! fs */ "?ec0a");
}
// Read file
fs.readFile(file, "utf8", function (err, content) {
@ -1695,8 +1705,8 @@ function readConfig(file, parsers, callback) {
var configExtends = config["extends"];
if (configExtends) {
delete config["extends"];
var resolvedExtends = resolveConfigExtends(file, configExtends);
return readConfig(resolvedExtends, parsers, function (errr, extendsConfig) {
var resolvedExtends = resolveConfigExtends(file, configExtends, fs);
return readConfig(resolvedExtends, parsers, fs, function (errr, extendsConfig) {
if (errr) {
return callback(errr);
}
@ -1712,22 +1722,26 @@ var readConfigPromisify = promisify && promisify(readConfig);
*
* @param {string} file Configuration file name.
* @param {ConfigurationParser[]} [parsers] Parsing function(s).
* @param {Object} [fs] File system implementation.
* @returns {Promise<Configuration>} Configuration object.
*/
function readConfigPromise(file, parsers) {
function readConfigPromise(file, parsers, fs) {
// @ts-ignore
return readConfigPromisify(file, parsers);
return readConfigPromisify(file, parsers, fs);
}
/**
* Read specified configuration file synchronously.
*
* @param {string} file Configuration file name.
* @param {ConfigurationParser[]} [parsers] Parsing function(s).
* @param {Object} [fs] File system implementation.
* @returns {Configuration} Configuration object.
*/
function readConfigSync(file, parsers) {
function readConfigSync(file, parsers, fs) {
if (!fs) {
fs = __webpack_require__(/*! fs */ "?ec0a");
}
// Read file
// @ts-ignore
var content = fs.readFileSync(file, "utf8");
// Try to parse file
var _a = parseConfiguration(file, content, parsers), config = _a.config, message = _a.message;
@ -1738,8 +1752,8 @@ function readConfigSync(file, parsers) {
var configExtends = config["extends"];
if (configExtends) {
delete config["extends"];
var resolvedExtends = resolveConfigExtends(file, configExtends);
return __assign(__assign({}, readConfigSync(resolvedExtends, parsers)), config);
var resolvedExtends = resolveConfigExtends(file, configExtends, fs);
return __assign(__assign({}, readConfigSync(resolvedExtends, parsers, fs)), config);
}
return config;
}

13
lib/markdownlint.d.ts vendored
View file

@ -52,6 +52,10 @@ type Options = {
* Additional plugins.
*/
markdownItPlugins?: Plugin[];
/**
* File system implementation.
*/
fs?: any;
};
/**
* Called with the result of the lint operation.
@ -70,18 +74,20 @@ declare function markdownlintSync(options: Options): LintResults;
* @param {string} file Configuration file name.
* @param {ConfigurationParser[] | ReadConfigCallback} parsers Parsing
* function(s).
* @param {Object} [fs] File system implementation.
* @param {ReadConfigCallback} [callback] Callback (err, result) function.
* @returns {void}
*/
declare function readConfig(file: string, parsers: ConfigurationParser[] | ReadConfigCallback, callback?: ReadConfigCallback): void;
declare function readConfig(file: string, parsers: ConfigurationParser[] | ReadConfigCallback, fs?: any, callback?: ReadConfigCallback): void;
/**
* Read specified configuration file synchronously.
*
* @param {string} file Configuration file name.
* @param {ConfigurationParser[]} [parsers] Parsing function(s).
* @param {Object} [fs] File system implementation.
* @returns {Configuration} Configuration object.
*/
declare function readConfigSync(file: string, parsers?: ConfigurationParser[]): Configuration;
declare function readConfigSync(file: string, parsers?: ConfigurationParser[], fs?: any): Configuration;
/**
* Gets the (semantic) version of the library.
*
@ -364,6 +370,7 @@ declare function markdownlintPromise(options: Options): Promise<LintResults>;
*
* @param {string} file Configuration file name.
* @param {ConfigurationParser[]} [parsers] Parsing function(s).
* @param {Object} [fs] File system implementation.
* @returns {Promise<Configuration>} Configuration object.
*/
declare function readConfigPromise(file: string, parsers?: ConfigurationParser[]): Promise<Configuration>;
declare function readConfigPromise(file: string, parsers?: ConfigurationParser[], fs?: any): Promise<Configuration>;

View file

@ -2,7 +2,6 @@
"use strict";
const fs = require("fs");
const path = require("path");
const { promisify } = require("util");
const markdownIt = require("markdown-it");
@ -685,6 +684,7 @@ function lintContent(
* @param {boolean} handleRuleFailures Whether to handle exceptions in rules.
* @param {boolean} noInlineConfig Whether to allow inline configuration.
* @param {number} resultVersion Version of the LintResults object to return.
* @param {Object} fs File system implementation.
* @param {boolean} synchronous Whether to execute synchronously.
* @param {Function} callback Callback (err, result) function.
* @returns {void}
@ -698,6 +698,7 @@ function lintFile(
handleRuleFailures,
noInlineConfig,
resultVersion,
fs,
synchronous,
callback) {
// eslint-disable-next-line jsdoc/require-jsdoc
@ -710,7 +711,6 @@ function lintFile(
}
// Make a/synchronous call to read file
if (synchronous) {
// @ts-ignore
lintContentWrapper(null, fs.readFileSync(file, "utf8"));
} else {
fs.readFile(file, "utf8", lintContentWrapper);
@ -756,6 +756,7 @@ function lintInput(options, synchronous, callback) {
// @ts-ignore
md.use(...plugin);
});
const fs = options.fs || require("fs");
const results = newResults(ruleList);
let done = false;
// Linting of strings is always synchronous
@ -795,6 +796,7 @@ function lintInput(options, synchronous, callback) {
handleRuleFailures,
noInlineConfig,
resultVersion,
fs,
synchronous,
syncCallback
);
@ -819,6 +821,7 @@ function lintInput(options, synchronous, callback) {
handleRuleFailures,
noInlineConfig,
resultVersion,
fs,
synchronous,
(err, result) => {
concurrency--;
@ -929,17 +932,17 @@ function parseConfiguration(name, content, parsers) {
*
* @param {string} configFile Configuration file name.
* @param {string} referenceId Referenced identifier to resolve.
* @param {Object} fs File system implementation.
* @returns {string} Resolved path to file.
*/
function resolveConfigExtends(configFile, referenceId) {
function resolveConfigExtends(configFile, referenceId, fs) {
const configFileDirname = path.dirname(configFile);
const resolvedExtendsFile = path.resolve(configFileDirname, referenceId);
try {
if (fs.statSync(resolvedExtendsFile).isFile()) {
return resolvedExtendsFile;
}
fs.accessSync(resolvedExtendsFile);
return resolvedExtendsFile;
} catch {
// If not a file or fs.statSync throws, try require.resolve
// Not a file, try require.resolve
}
try {
return dynamicRequire.resolve(
@ -947,7 +950,7 @@ function resolveConfigExtends(configFile, referenceId) {
{ "paths": [ configFileDirname ] }
);
} catch {
// If require.resolve throws, return resolvedExtendsFile
// Unable to resolve, return resolvedExtendsFile
}
return resolvedExtendsFile;
}
@ -958,14 +961,23 @@ function resolveConfigExtends(configFile, referenceId) {
* @param {string} file Configuration file name.
* @param {ConfigurationParser[] | ReadConfigCallback} parsers Parsing
* function(s).
* @param {Object} [fs] File system implementation.
* @param {ReadConfigCallback} [callback] Callback (err, result) function.
* @returns {void}
*/
function readConfig(file, parsers, callback) {
function readConfig(file, parsers, fs, callback) {
if (!callback) {
// @ts-ignore
callback = parsers;
parsers = null;
if (fs) {
callback = fs;
fs = null;
} else {
// @ts-ignore
callback = parsers;
parsers = null;
}
}
if (!fs) {
fs = require("fs");
}
// Read file
fs.readFile(file, "utf8", (err, content) => {
@ -982,8 +994,8 @@ function readConfig(file, parsers, callback) {
const configExtends = config.extends;
if (configExtends) {
delete config.extends;
const resolvedExtends = resolveConfigExtends(file, configExtends);
return readConfig(resolvedExtends, parsers, (errr, extendsConfig) => {
const resolvedExtends = resolveConfigExtends(file, configExtends, fs);
return readConfig(resolvedExtends, parsers, fs, (errr, extendsConfig) => {
if (errr) {
return callback(errr);
}
@ -1004,11 +1016,12 @@ const readConfigPromisify = promisify && promisify(readConfig);
*
* @param {string} file Configuration file name.
* @param {ConfigurationParser[]} [parsers] Parsing function(s).
* @param {Object} [fs] File system implementation.
* @returns {Promise<Configuration>} Configuration object.
*/
function readConfigPromise(file, parsers) {
function readConfigPromise(file, parsers, fs) {
// @ts-ignore
return readConfigPromisify(file, parsers);
return readConfigPromisify(file, parsers, fs);
}
/**
@ -1016,11 +1029,14 @@ function readConfigPromise(file, parsers) {
*
* @param {string} file Configuration file name.
* @param {ConfigurationParser[]} [parsers] Parsing function(s).
* @param {Object} [fs] File system implementation.
* @returns {Configuration} Configuration object.
*/
function readConfigSync(file, parsers) {
function readConfigSync(file, parsers, fs) {
if (!fs) {
fs = require("fs");
}
// Read file
// @ts-ignore
const content = fs.readFileSync(file, "utf8");
// Try to parse file
const { config, message } = parseConfiguration(file, content, parsers);
@ -1031,9 +1047,9 @@ function readConfigSync(file, parsers) {
const configExtends = config.extends;
if (configExtends) {
delete config.extends;
const resolvedExtends = resolveConfigExtends(file, configExtends);
const resolvedExtends = resolveConfigExtends(file, configExtends, fs);
return {
...readConfigSync(resolvedExtends, parsers),
...readConfigSync(resolvedExtends, parsers, fs),
...config
};
}
@ -1156,6 +1172,7 @@ module.exports = markdownlint;
* @property {boolean} [noInlineConfig] True to ignore HTML directives.
* @property {number} [resultVersion] Results object version.
* @property {Plugin[]} [markdownItPlugins] Additional plugins.
* @property {Object} [fs] File system implementation.
*/
/**

View file

@ -652,11 +652,13 @@ test.cb("readmeHeadings", (t) => {
"##### options.noInlineConfig",
"##### options.resultVersion",
"##### options.markdownItPlugins",
"##### options.fs",
"#### callback",
"#### result",
"### Config",
"#### file",
"#### parsers",
"#### fs",
"#### callback",
"#### result",
"## Usage",
@ -798,6 +800,40 @@ test.cb("missingStringValue", (t) => {
});
});
test("customFileSystemSync", (t) => {
t.plan(2);
const file = "/dir/file.md";
const fsApi = {
"readFileSync": (p) => {
t.is(p, file);
return "# Heading";
}
};
const result = markdownlint.sync({
"files": file,
"fs": fsApi
});
t.deepEqual(result[file].length, 1, "Did not report violations.");
});
test.cb("customFileSystemAsync", (t) => {
t.plan(3);
const file = "/dir/file.md";
const fsApi = {
"readFile": (p, o, cb) => {
t.is(p, file);
cb(null, "# Heading");
}
};
markdownlint({
"files": file,
"fs": fsApi
}, function callback(err, result) {
t.falsy(err);
t.deepEqual(result[file].length, 1, "Did not report violations.");
t.end();
});
});
test.cb("readme", (t) => {
t.plan(115);
const tagToRules = {};
@ -1105,6 +1141,52 @@ test.cb("configMultipleWithRequireResolve", (t) => {
});
});
test.cb("configCustomFileSystem", (t) => {
t.plan(5);
const file = "/dir/file.json";
const extended = "/dir/extended.json";
const fileContent = {
"extends": extended,
"default": true,
"MD001": false
};
const extendedContent = {
"MD001": true,
"MD002": true
};
const fsApi = {
"accessSync": (p) => {
t.is(p, extended);
},
"readFile": (p, o, cb) => {
switch (p) {
case file:
t.is(p, file);
return cb(null, JSON.stringify(fileContent));
case extended:
t.is(p, extended);
return cb(null, JSON.stringify(extendedContent));
default:
return t.fail();
}
}
};
markdownlint.readConfig(
file,
null,
fsApi,
function callback(err, actual) {
t.falsy(err);
const expected = {
...extendedContent,
...fileContent
};
delete expected.extends;
t.deepEqual(actual, expected, "Config object not correct.");
t.end();
});
});
test.cb("configBadFile", (t) => {
t.plan(4);
markdownlint.readConfig("./test/config/config-badfile.json",
@ -1358,6 +1440,45 @@ test("configMultipleHybridSync", (t) => {
t.like(actual, expected, "Config object not correct.");
});
test("configCustomFileSystemSync", (t) => {
t.plan(4);
const file = "/dir/file.json";
const extended = "/dir/extended.json";
const fileContent = {
"extends": extended,
"default": true,
"MD001": false
};
const extendedContent = {
"MD001": true,
"MD002": true
};
const fsApi = {
"accessSync": (p) => {
t.is(p, extended);
},
"readFileSync": (p) => {
switch (p) {
case file:
t.is(p, file);
return JSON.stringify(fileContent);
case extended:
t.is(p, extended);
return JSON.stringify(extendedContent);
default:
return t.fail();
}
}
};
const actual = markdownlint.readConfigSync(file, null, fsApi);
const expected = {
...extendedContent,
...fileContent
};
delete expected.extends;
t.deepEqual(actual, expected, "Config object not correct.");
});
test("configBadHybridSync", (t) => {
t.plan(1);
t.throws(
@ -1385,6 +1506,48 @@ test.cb("configSinglePromise", (t) => {
});
});
test.cb("configCustomFileSystemPromise", (t) => {
t.plan(4);
const file = "/dir/file.json";
const extended = "/dir/extended.json";
const fileContent = {
"extends": extended,
"default": true,
"MD001": false
};
const extendedContent = {
"MD001": true,
"MD002": true
};
const fsApi = {
"accessSync": (p) => {
t.is(p, extended);
},
"readFile": (p, o, cb) => {
switch (p) {
case file:
t.is(p, file);
return cb(null, JSON.stringify(fileContent));
case extended:
t.is(p, extended);
return cb(null, JSON.stringify(extendedContent));
default:
return t.fail();
}
}
};
markdownlint.promises.readConfig(file, null, fsApi)
.then((actual) => {
const expected = {
...extendedContent,
...fileContent
};
delete expected.extends;
t.deepEqual(actual, expected, "Config object not correct.");
t.end();
});
});
test.cb("configBadFilePromise", (t) => {
t.plan(2);
markdownlint.promises.readConfig("./test/config/config-badfile.json")