Add parsers parameter to readConfig/Sync to support non-JSON formats like YAML (fixes #118).

This commit is contained in:
David Anson 2018-05-23 22:24:40 -07:00
parent 2b4ecdced8
commit 101edd8496
10 changed files with 252 additions and 31 deletions

View file

@ -400,9 +400,13 @@ uses rule aliases (ex: `no-hard-tabs`) instead of names (ex: `MD010`).
### Config
The `options.config` configuration object is simple and can be loaded as JSON
in many cases. To take advantage of shared configuration where one file `extends`
another, the following functions are useful.
The `options.config` configuration object is simple and can be stored in a file
for readability and easy reuse. The `readConfig` and `readConfigSync` functions
load configuration settings and support the `extends` keyword for referencing
other files (see above).
By default, configuration files are parsed as JSON (and named `.markdownlint.json`).
Custom parsers can be provided to handle other formats like JSONC, YAML, and TOML.
Asynchronous interface:
@ -411,10 +415,11 @@ Asynchronous interface:
* Read specified configuration file.
*
* @param {String} file Configuration file name/path.
* @param {Array} [parsers] Optional parsing function(s).
* @param {Function} callback Callback (err, result) function.
* @returns {void}
*/
function readConfig(file, callback) { ... }
function readConfig(file, parsers, callback) { ... }
```
Synchronous interface:
@ -424,16 +429,17 @@ Synchronous interface:
* Read specified configuration file synchronously.
*
* @param {String} file Configuration file name/path.
* @param {Array} [parsers] Optional parsing function(s).
* @returns {Object} Configuration object.
*/
function readConfigSync(file) { ... }
function readConfigSync(file, parsers) { ... }
```
#### file
Type: `String`
Location of JSON configuration file to read.
Location of configuration file to read.
The `file` is resolved relative to the current working directory. If an `extends`
key is present once read, its value will be resolved as a path relative to `file`
@ -441,6 +447,22 @@ and loaded recursively. Settings from a file referenced by `extends` are applied
first, then those of `file` are applied on top (overriding any of the same keys
appearing in the referenced file).
#### parsers
Type: *Optional* `Array` of `Function` taking (`String`) and returning `Object`
Array of functions to parse configuration files.
The contents of a configuration file are passed to each parser function until one
of them returns a value (vs. throwing an exception). Consequently, strict parsers
should come before flexible parsers.
For example:
```js
[ JSON.parse, require("toml").parse, require("js-yaml").safeLoad ]
```
#### callback
Type: `Function` taking (`Error`, `Object`)

View file

@ -422,6 +422,7 @@ function lintFile(
}
}
// Lints files and strings
function lintInput(options, synchronous, callback) {
// Normalize inputs
options = options || {};
@ -517,34 +518,64 @@ function markdownlintSync(options) {
return results;
}
// Parses the content of a configuration file
function parseConfiguration(name, content, parsers) {
let config = null;
let message = "";
const errors = [];
// Try each parser
(parsers || [ JSON.parse ]).every((parser) => {
try {
config = parser(content);
} catch (ex) {
errors.push(ex.message);
}
return !config;
});
// Message if unable to parse
if (!config) {
errors.unshift(`Unable to parse '${name}'`);
message = errors.join("; ");
}
return {
config,
message
};
}
/**
* Read specified configuration file.
*
* @param {String} file Configuration file name/path.
* @param {Array} [parsers] Optional parsing function(s).
* @param {Function} callback Callback (err, result) function.
* @returns {void}
*/
function readConfig(file, callback) {
function readConfig(file, parsers, callback) {
if (!callback) {
// @ts-ignore
callback = parsers;
parsers = null;
}
// Read file
fs.readFile(file, shared.utf8Encoding, function handleFile(err, content) {
fs.readFile(file, shared.utf8Encoding, (err, content) => {
if (err) {
return callback(err);
}
// Parse file
let config = null;
try {
config = JSON.parse(content);
} catch (ex) {
return callback(ex);
// Try to parse file
const { config, message } = parseConfiguration(file, content, parsers);
if (!config) {
return callback(new Error(message));
}
if (config.extends) {
// Extend configuration
const extendsFile = path.resolve(path.dirname(file), config.extends);
readConfig(extendsFile, function handleConfig(errr, extendsConfig) {
const configExtends = config.extends;
if (configExtends) {
delete config.extends;
const extendsFile = path.resolve(path.dirname(file), configExtends);
readConfig(extendsFile, parsers, (errr, extendsConfig) => {
if (errr) {
return callback(errr);
}
delete config.extends;
callback(null, shared.assign(extendsConfig, config));
});
} else {
@ -557,17 +588,24 @@ function readConfig(file, callback) {
* Read specified configuration file synchronously.
*
* @param {String} file Configuration file name/path.
* @param {Array} [parsers] Optional parsing function(s).
* @returns {Object} Configuration object.
*/
function readConfigSync(file) {
// Parse file
let config = JSON.parse(fs.readFileSync(file, shared.utf8Encoding));
if (config.extends) {
function readConfigSync(file, parsers) {
// Read file
const content = fs.readFileSync(file, shared.utf8Encoding);
// Try to parse file
const { config, message } = parseConfiguration(file, content, parsers);
if (!config) {
throw new Error(message);
}
// Extend configuration
config = shared.assign(
readConfigSync(path.resolve(path.dirname(file), config.extends)),
config);
const configExtends = config.extends;
if (configExtends) {
delete config.extends;
return shared.assign(
readConfigSync(path.resolve(path.dirname(file), configExtends), parsers),
config);
}
return config;
}

View file

@ -35,8 +35,10 @@
"eslint": "~4.19.1",
"glob": "~7.1.2",
"istanbul": "~0.4.5",
"js-yaml": "~3.11.0",
"nodeunit": "~0.11.2",
"rimraf": "~2.6.2",
"toml": "~2.3.3",
"tv4": "~1.3.0",
"typescript": "~2.8.3",
"uglify-js": "~3.3.25"

View file

@ -0,0 +1 @@
@

View file

@ -0,0 +1,2 @@
no-hard-tabs: false
whitespace: false

View file

@ -0,0 +1,6 @@
extends: config-parent-hybrid.toml
MD003:
style: atx_closed
MD007:
indent: 2
no-hard-tabs: false

View file

@ -0,0 +1,6 @@
extends: config-parent.yaml
MD003:
style: atx_closed
MD007:
indent: 2
no-hard-tabs: false

View file

@ -0,0 +1,9 @@
extends = "config-child.json"
MD003 = false
no-hard-tabs = true
[MD007]
indent = 4
[line-length]
line_length = 200

View file

@ -0,0 +1,7 @@
extends: config-child.yaml
MD003: false
MD007:
indent: 4
no-hard-tabs: true
line-length:
line_length: 200

View file

@ -924,6 +924,7 @@ module.exports.readmeHeadings = function readmeHeadings(test) {
"#### result",
"### Config",
"#### file",
"#### parsers",
"#### callback",
"#### result",
"## Usage",
@ -1466,6 +1467,72 @@ module.exports.configBadChildJson = function configBadChildJson(test) {
});
};
module.exports.configSingleYaml = function configSingleYaml(test) {
test.expect(2);
markdownlint.readConfig(
"./test/config/config-child.yaml",
[ require("js-yaml").safeLoad ],
function callback(err, actual) {
test.ifError(err);
const expected = require("./config/config-child.json");
test.deepEqual(actual, expected, "Config object not correct.");
test.done();
});
};
module.exports.configMultipleYaml = function configMultipleYaml(test) {
test.expect(2);
markdownlint.readConfig(
"./test/config/config-grandparent.yaml",
[ require("js-yaml").safeLoad ],
function callback(err, actual) {
test.ifError(err);
const expected = shared.assign(
shared.assign(
shared.assign({}, require("./config/config-child.json")),
require("./config/config-parent.json")),
require("./config/config-grandparent.json"));
delete expected.extends;
test.deepEqual(actual, expected, "Config object not correct.");
test.done();
});
};
module.exports.configMultipleHybrid = function configMultipleHybrid(test) {
test.expect(2);
markdownlint.readConfig(
"./test/config/config-grandparent-hybrid.yaml",
[ JSON.parse, require("toml").parse, require("js-yaml").safeLoad ],
function callback(err, actual) {
test.ifError(err);
const expected = shared.assign(
shared.assign(
shared.assign({}, require("./config/config-child.json")),
require("./config/config-parent.json")),
require("./config/config-grandparent.json"));
delete expected.extends;
test.deepEqual(actual, expected, "Config object not correct.");
test.done();
});
};
module.exports.configBadHybrid = function configBadHybrid(test) {
test.expect(4);
markdownlint.readConfig(
"./test/config/config-badcontent.txt",
[ JSON.parse, require("toml").parse, require("js-yaml").safeLoad ],
function callback(err, result) {
test.ok(err, "Did not get an error for bad child JSON.");
test.ok(err instanceof Error, "Error not instance of Error.");
test.ok(err.message.match(
// eslint-disable-next-line max-len
/^Unable to parse '[^']*'; Unexpected token \S+ in JSON at position \d+; Expected [^;]+ or end of input but "\S+" found.; end of the stream or a document separator is expected at line \d+, column \d+:[^;]*$/
), "Error message unexpected.");
test.ok(!result, "Got result for bad child JSON.");
test.done();
});
};
module.exports.configSingleSync = function configSingleSync(test) {
test.expect(1);
const actual = markdownlint.readConfigSync("./test/config/config-child.json");
@ -1530,8 +1597,10 @@ module.exports.configBadJsonSync = function configBadJsonSync(test) {
}, function testError(err) {
test.ok(err, "Did not get an error for bad JSON.");
test.ok(err instanceof Error, "Error not instance of Error.");
test.ok(err.message.match(/Unexpected token b in JSON at position \d+/),
"Error message unexpected.");
test.ok(err.message.match(
// eslint-disable-next-line max-len
/^Unable to parse '[^']*'; Unexpected token \S+ in JSON at position \d+$/
), "Error message unexpected.");
return true;
}, "Did not get exception for bad JSON.");
test.done();
@ -1544,13 +1613,72 @@ module.exports.configBadChildJsonSync = function configBadChildJsonSync(test) {
}, function testError(err) {
test.ok(err, "Did not get an error for bad child JSON.");
test.ok(err instanceof Error, "Error not instance of Error.");
test.ok(err.message.match(/Unexpected token b in JSON at position \d+/),
"Error message unexpected.");
test.ok(err.message.match(
// eslint-disable-next-line max-len
/^Unable to parse '[^']*'; Unexpected token \S+ in JSON at position \d+$/
), "Error message unexpected.");
return true;
}, "Did not get exception for bad child JSON.");
test.done();
};
module.exports.configSingleYamlSync = function configSingleYamlSync(test) {
test.expect(1);
const actual = markdownlint.readConfigSync(
"./test/config/config-child.yaml", [ require("js-yaml").safeLoad ]);
const expected = require("./config/config-child.json");
test.deepEqual(actual, expected, "Config object not correct.");
test.done();
};
module.exports.configMultipleYamlSync = function configMultipleYamlSync(test) {
test.expect(1);
const actual = markdownlint.readConfigSync(
"./test/config/config-grandparent.yaml", [ require("js-yaml").safeLoad ]);
const expected = shared.assign(
shared.assign(
shared.assign({}, require("./config/config-child.json")),
require("./config/config-parent.json")),
require("./config/config-grandparent.json"));
delete expected.extends;
test.deepEqual(actual, expected, "Config object not correct.");
test.done();
};
module.exports.configMultipleHybridSync =
function configMultipleHybridSync(test) {
test.expect(1);
const actual = markdownlint.readConfigSync(
"./test/config/config-grandparent-hybrid.yaml",
[ JSON.parse, require("toml").parse, require("js-yaml").safeLoad ]);
const expected = shared.assign(
shared.assign(
shared.assign({}, require("./config/config-child.json")),
require("./config/config-parent.json")),
require("./config/config-grandparent.json"));
delete expected.extends;
test.deepEqual(actual, expected, "Config object not correct.");
test.done();
};
module.exports.configBadHybridSync = function configBadHybridSync(test) {
test.expect(4);
test.throws(function badHybridCall() {
markdownlint.readConfigSync(
"./test/config/config-badcontent.txt",
[ JSON.parse, require("toml").parse, require("js-yaml").safeLoad ]);
}, function testError(err) {
test.ok(err, "Did not get an error for bad content.");
test.ok(err instanceof Error, "Error not instance of Error.");
test.ok(err.message.match(
// eslint-disable-next-line max-len
/^Unable to parse '[^']*'; Unexpected token \S+ in JSON at position \d+; Expected [^;]+ or end of input but "\S+" found.; end of the stream or a document separator is expected at line \d+, column \d+:[^;]*$/
), "Error message unexpected.");
return true;
}, "Did not get exception for bad content.");
test.done();
};
module.exports.customRulesV0 = function customRulesV0(test) {
test.expect(4);
const customRulesMd = "./test/custom-rules.md";