src/index.js
const _ = require('lodash/fp');
const jsonService = require('vscode-json-languageservice');
const jsonServiceHandle = jsonService.getLanguageService({});
const ErrorCodes = {
Undefined: 0,
EnumValueMismatch: 1,
UnexpectedEndOfComment: 0x101,
UnexpectedEndOfString: 0x102,
UnexpectedEndOfNumber: 0x103,
InvalidUnicode: 0x104,
InvalidEscapeCharacter: 0x105,
InvalidCharacter: 0x106,
PropertyExpected: 0x201,
CommaExpected: 0x202,
ColonExpected: 0x203,
ValueExpected: 0x204,
CommaOrCloseBacketExpected: 0x205,
CommaOrCloseBraceExpected: 0x206,
TrailingComma: 0x207,
DuplicateKey: 0x208,
CommentNotPermitted: 0x209,
SchemaResolveError: 0x300,
};
const AllErrorCodes = _.values(ErrorCodes);
const AllowComments = 'allowComments';
const fileLintResults = {};
const fileComments = {};
const fileDocuments = {};
const getSignature = (problem) =>
`${problem.range.start.line} ${problem.range.start.character} ${problem.message}`;
function getDiagnostics(jsonDocument) {
return _.pipe(
_.map((problem) => [getSignature(problem), problem]),
_.reverse, // reverse ensure fromPairs keep first signature occurence of problem
_.fromPairs
)(jsonDocument.syntaxErrors);
}
const reportError = (filter) => (errorName, context) => {
_.filter(filter, fileLintResults[context.getFilename()]).forEach((error) => {
context.report({
ruleId: `json/${errorName}`,
message: error.message,
loc: {
start: {line: error.range.start.line + 1, column: error.range.start.character},
end: {line: error.range.end.line + 1, column: error.range.end.character},
},
// later: see how to add fix
});
});
};
const reportComment = (errorName, context) => {
const ruleOption = _.head(context.options);
if (ruleOption === AllowComments || _.get(AllowComments, ruleOption)) return;
_.forEach((comment) => {
context.report({
ruleId: errorName,
message: 'Comment not allowed',
loc: {
start: {line: comment.start.line + 1, column: comment.start.character},
end: {line: comment.end.line + 1, column: comment.end.character},
},
});
}, fileComments[context.getFilename()]);
};
const ruleSchema = [
{
anyOf: [
{
enum: ['allowComments'],
},
{
type: 'object',
properties: {
allowComments: {type: 'boolean'},
},
additionalProperties: false,
},
],
},
];
const makeRule = (errorName, reporters) => ({
meta: {schema: ruleSchema},
create(context) {
return {
Program() {
_.flatten([reporters]).map((reporter) => reporter(errorName, context));
},
};
},
});
const rules = _.pipe(
_.mapKeys(_.kebabCase),
_.toPairs,
_.map(([errorName, errorCode]) => [
errorName,
makeRule(
errorName,
reportError((err) => err.code === errorCode)
),
]),
_.fromPairs,
_.assign({
'*': makeRule('*', [reportError(_.constant(true)), reportComment]),
json: makeRule('json', [reportError(_.constant(true)), reportComment]),
unknown: makeRule('unknown', reportError(_.negate(AllErrorCodes.includes))),
'comment-not-permitted': makeRule('comment-not-permitted', reportComment),
})
)(ErrorCodes);
const errorSignature = (err) =>
['message', 'line', 'column', 'endLine', 'endColumn'].map((field) => err[field]).join('::');
const getErrorCode = _.pipe(_.get('ruleId'), _.split('/'), _.last);
const meta = {
name: 'eslint-plugin-json',
version: '3.1.0',
};
const jsonProcessor = {
preprocess: function (text, fileName) {
const textDocument = jsonService.TextDocument.create(fileName, 'json', 1, text);
fileDocuments[fileName] = textDocument;
const parsed = jsonServiceHandle.parseJSONDocument(textDocument);
fileLintResults[fileName] = getDiagnostics(parsed);
fileComments[fileName] = parsed.comments;
return ['']; // sorry nothing ;)
},
postprocess: function (messages, fileName) {
const textDocument = fileDocuments[fileName];
delete fileLintResults[fileName];
delete fileComments[fileName];
return _.pipe(
_.first,
_.groupBy(errorSignature),
_.mapValues((errors) => {
if (errors.length === 1) return _.first(errors);
// Otherwise there is two errors: the generic and specific one
// json/* or json/json and json/some-code
const firstErrorCode = getErrorCode(errors[0]);
const isFirstGeneric = ['*', 'json'].includes(firstErrorCode);
const genericError = errors[isFirstGeneric ? 0 : 1];
const specificError = errors[isFirstGeneric ? 1 : 0];
return genericError.severity > specificError.severity
? genericError
: specificError;
}),
_.mapValues((error) => {
const source = textDocument.getText({
start: {line: error.line - 1, character: error.column},
end: {line: error.endLine - 1, character: error.endColumn},
});
return _.assign(error, {
source,
column: error.column + 1,
endColumn: error.endColumn + 1,
});
}),
_.values
)(messages);
},
};
const processors = {
// Supports old config.
'.json': jsonProcessor,
// Supports new config.
json: jsonProcessor,
};
const configs = {
'recommended-legacy': {
plugins: ['json'],
rules: {
'json/*': 'error',
},
},
'recommended-with-comments-legacy': {
plugins: ['json'],
rules: {
'json/*': ['error', {allowComments: true}],
},
},
};
const json = {meta, rules, configs, processors};
json.configs['recommended'] = {
files: ['**/*.json'],
plugins: {
json,
},
rules: {
'json/*': 'error',
},
processor: 'json/json',
};
json.configs['recommended-with-comments'] = {
files: ['**/*.json'],
plugins: {
json,
},
rules: {
'json/*': ['error', {allowComments: true}],
},
processor: 'json/json',
};
module.exports = json;